diff --git a/CHANGELOG.md b/CHANGELOG.md index 9901b8b..495f05f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). +## [Unreleased] + +### Added + +- Manage dashboard access rights per Group and per User (in addition to per Profile) + ## [1.5.0] - 2026-10-06 ### Added diff --git a/front/itemright.form.php b/front/itemright.form.php new file mode 100644 index 0000000..426ed64 --- /dev/null +++ b/front/itemright.form.php @@ -0,0 +1,93 @@ +. + * ------------------------------------------------------------------------- + * @copyright Copyright (C) 2018-2023 by Metabase plugin team. + * @license GPLv2 https://www.gnu.org/licenses/gpl-2.0.html + * @link https://github.com/pluginsGLPI/metabase + * ------------------------------------------------------------------------- + */ + +$itemtype = $_POST['itemtype'] ?? null; +$items_id = (int) ($_POST['items_id'] ?? 0); + +$item = match ($itemtype) { + Group::class => new Group(), + User::class => new User(), + default => null, +}; + +if ($item === null || $items_id <= 0 || !$item->getFromDB($items_id)) { + Session::addMessageAfterRedirect( + __s('Invalid request.', 'metabase'), + false, + ERROR, + ); + Html::back(); +} + +// UPDATE right on the targeted group / user is required to change its dashboard rights. +$item->check($items_id, UPDATE); + +$viewable_dashboards_ids = []; + +if (isset($_POST['update'])) { + if (!isset($_POST['dashboard']) || !is_array($_POST['dashboard'])) { + Session::addMessageAfterRedirect( + __s('Invalid request.', 'metabase'), + false, + ERROR, + ); + Html::back(); + } + + foreach ($_POST['dashboard'] as $dashboard_id => $rights) { + $rights = ((int) $rights & READ) !== 0 ? READ : 0; + + PluginMetabaseItemright::setDashboardRightsForItem($item::class, $items_id, (int) $dashboard_id, $rights); + + if ($rights !== 0) { + $viewable_dashboards_ids[] = (int) $dashboard_id; + } + } +} elseif (isset($_POST['set_rights_to_all'])) { + $rights = ((int) $_POST['set_rights_to_all'] & READ) !== 0 ? READ : 0; + + $apiclient = new PluginMetabaseAPIClient(); + $dashboards = $apiclient->getDashboards(); + + foreach (is_array($dashboards) ? $dashboards : [] as $dashboard) { + PluginMetabaseItemright::setDashboardRightsForItem($item::class, $items_id, (int) $dashboard['id'], $rights); + + if ($rights !== 0) { + $viewable_dashboards_ids[] = (int) $dashboard['id']; + } + } +} + +if ($viewable_dashboards_ids !== []) { + $apiclient = new PluginMetabaseAPIClient(); + $apiclient->enableDashboardsEmbeddedDisplay($viewable_dashboards_ids); +} + +Html::back(); diff --git a/inc/dashboard.class.php b/inc/dashboard.class.php index 9ad8cde..5b9a0ff 100644 --- a/inc/dashboard.class.php +++ b/inc/dashboard.class.php @@ -43,6 +43,36 @@ public static function getTypeName($nb = 0) return __s('Metabase dashboard', 'metabase'); } + /** + * Check if the current user is able to view at least one dashboard. + * + * Rights are additive (OR) across profile / groups / user. + * + * @return bool + */ + public static function canCurrentUserViewDashboards(): bool + { + return PluginMetabaseProfileright::canProfileViewDashboards($_SESSION['glpiactiveprofile']['id']) + || PluginMetabaseItemright::canItemsViewDashboards(User::class, [Session::getLoginUserID()]) + || PluginMetabaseItemright::canItemsViewDashboards(Group::class, $_SESSION['glpigroups'] ?? []); + } + + /** + * Check if the current user is able to view the given dashboard. + * + * Rights are additive (OR) across profile / groups / user. + * + * @param int $dashboardId Metabase dashboard id + * + * @return bool + */ + public static function canCurrentUserViewDashboard(int $dashboardId): bool + { + return PluginMetabaseProfileright::canProfileViewDashboard($_SESSION['glpiactiveprofile']['id'], $dashboardId) !== 0 + || PluginMetabaseItemright::canItemsViewDashboard(User::class, [Session::getLoginUserID()], $dashboardId) + || PluginMetabaseItemright::canItemsViewDashboard(Group::class, $_SESSION['glpigroups'] ?? [], $dashboardId); + } + /** * {@inheritDoc} * @see CommonGLPI::getTabNameForItem() @@ -51,7 +81,7 @@ public function getTabNameForItem(CommonGLPI $item, $withtemplate = 0) { switch ($item->getType()) { case 'Central': - if (PluginMetabaseProfileright::canProfileViewDashboards($_SESSION['glpiactiveprofile']['id'])) { + if (self::canCurrentUserViewDashboards()) { return self::createTabEntry(self::getTypeName(), 0, $item::getType(), PluginMetabaseConfig::getIcon()); } @@ -69,7 +99,7 @@ public static function displayTabContentForItem(CommonGLPI $item, $tabnum = 1, $ { switch (get_class($item)) { case Central::class: - if (PluginMetabaseProfileright::canProfileViewDashboards($_SESSION['glpiactiveprofile']['id'])) { + if (self::canCurrentUserViewDashboards()) { self::showForCentral($item, $withtemplate); } @@ -99,10 +129,7 @@ public static function showForCentral(Central $item, $withtemplate = 0, $is_help $dashboards, function ($dashboard) { $isEmbeddingEnabled = $dashboard['enable_embedding']; - $canView = PluginMetabaseProfileright::canProfileViewDashboard( - $_SESSION['glpiactiveprofile']['id'], - $dashboard['id'], - ); + $canView = self::canCurrentUserViewDashboard((int) $dashboard['id']); return $isEmbeddingEnabled && $canView; }, diff --git a/inc/itemright.class.php b/inc/itemright.class.php new file mode 100644 index 0000000..91f7df3 --- /dev/null +++ b/inc/itemright.class.php @@ -0,0 +1,328 @@ +. + * ------------------------------------------------------------------------- + * @copyright Copyright (C) 2018-2023 by Metabase plugin team. + * @license GPLv2 https://www.gnu.org/licenses/gpl-2.0.html + * @link https://github.com/pluginsGLPI/metabase + * ------------------------------------------------------------------------- + */ + +use Glpi\Application\View\TemplateRenderer; + +/** + * Dashboard access rights granted to a Group or to a User. + * + * Works alongside PluginMetabaseProfileright: a dashboard is visible when the + * active profile, one of the user's groups or the user itself has READ access. + */ +class PluginMetabaseItemright extends CommonDBTM +{ + /** + * Itemtypes that can receive dashboard rights. + */ + public const SUPPORTED_ITEMTYPES = [Group::class, User::class]; + + /** + * {@inheritDoc} + * @see CommonGLPI::getTypeName() + */ + public static function getTypeName($nb = 0) + { + return __s('Metabase', 'metabase'); + } + + /** + * Check whether given itemtype can receive dashboard rights. + * + * @param mixed $itemtype + * + * @return bool + */ + public static function isSupportedItemtype($itemtype): bool + { + return in_array($itemtype, self::SUPPORTED_ITEMTYPES, true); + } + + /** + * {@inheritDoc} + * @see CommonGLPI::getTabNameForItem() + */ + public function getTabNameForItem(CommonGLPI $item, $withtemplate = 0) + { + if ( + $item instanceof CommonDBTM + && self::isSupportedItemtype($item::class) + && !$item->isNewItem() + && $item::canView() + ) { + return self::createTabEntry(self::getTypeName(), 0, $item::getType(), PluginMetabaseConfig::getIcon()); + } + + return ''; + } + + /** + * {@inheritDoc} + * @see CommonGLPI::displayTabContentForItem() + */ + public static function displayTabContentForItem(CommonGLPI $item, $tabnum = 1, $withtemplate = 0) + { + if ( + $item instanceof CommonDBTM + && self::isSupportedItemtype($item::class) + && $item->can($item->getID(), READ) + ) { + self::showForItem($item); + } + + return true; + } + + /** + * Display dashboard rights matrix for given group or user. + * + * READ right on the item is required to see the matrix, + * UPDATE right on the item is required to change it. + * + * @param CommonDBTM $item Group or User + * + * @return bool + */ + public static function showForItem(CommonDBTM $item): bool + { + $apiclient = new PluginMetabaseAPIClient(); + $dashboards = $apiclient->getDashboards(); + + if (!is_array($dashboards) || $dashboards === []) { + TemplateRenderer::getInstance()->display('@metabase/itemright.html.twig', [ + 'no_dashboard' => true, + ]); + return false; + } + + $rights = self::getRightsForItem($item::class, $item->getID()); + + $dashboards_data = []; + foreach ($dashboards as $dashboard) { + $dashboards_data[] = [ + 'id' => (int) $dashboard['id'], + 'name' => $dashboard['name'], + 'rights' => $rights[(int) $dashboard['id']] ?? 0, + ]; + } + + TemplateRenderer::getInstance()->display('@metabase/itemright.html.twig', [ + 'no_dashboard' => false, + 'form_url' => self::getFormURL(), + 'itemtype' => $item::class, + 'items_id' => $item->getID(), + 'can_update' => $item->can($item->getID(), UPDATE), + 'dashboards' => $dashboards_data, + 'right_choices' => [ + 0 => __('No access'), + READ => __('Read'), + ], + ]); + + return true; + } + + /** + * Check if the given groups or user are able to view at least one dashboard. + * + * @param string $itemtype Group::class or User::class + * @param int[] $items_ids + * + * @return bool + */ + public static function canItemsViewDashboards(string $itemtype, array $items_ids): bool + { + return self::countViewableRights($itemtype, $items_ids) > 0; + } + + /** + * Check if the given groups or user are able to view given dashboard. + * + * @param string $itemtype Group::class or User::class + * @param int[] $items_ids + * @param int $dashboard_id Metabase dashboard id + * + * @return bool + */ + public static function canItemsViewDashboard(string $itemtype, array $items_ids, int $dashboard_id): bool + { + return self::countViewableRights($itemtype, $items_ids, $dashboard_id) > 0; + } + + /** + * Count rights rows granting READ access to the given items. + * + * @param string $itemtype + * @param int[] $items_ids + * @param int|null $dashboard_id Restrict to this dashboard if not null + * + * @return int + */ + private static function countViewableRights(string $itemtype, array $items_ids, ?int $dashboard_id = null): int + { + $items_ids = array_values(array_filter(array_map('intval', $items_ids))); + if (!self::isSupportedItemtype($itemtype) || $items_ids === []) { + return 0; + } + + $where = [ + 'itemtype' => $itemtype, + 'items_id' => $items_ids, + 'rights' => ['&', READ], + ]; + if ($dashboard_id !== null) { + $where['dashboard_id'] = $dashboard_id; + } + + return countElementsInTable(self::getTable(), $where); + } + + /** + * Returns rights of given item, indexed by dashboard id. + * + * @param string $itemtype + * @param int $items_id + * + * @return array + */ + private static function getRightsForItem(string $itemtype, int $items_id): array + { + /** @var DBmysql $DB */ + global $DB; + + $iterator = $DB->request([ + 'SELECT' => ['dashboard_id', 'rights'], + 'FROM' => self::getTable(), + 'WHERE' => [ + 'itemtype' => $itemtype, + 'items_id' => $items_id, + ], + ]); + + $rights = []; + foreach ($iterator as $row) { + $rights[(int) $row['dashboard_id']] = (int) $row['rights']; + } + + return $rights; + } + + /** + * Defines rights of given item for given dashboard. + * + * @param string $itemtype Group::class or User::class + * @param int $items_id + * @param int $dashboard_id Metabase dashboard id + * @param int $rights + * + * @return void + */ + public static function setDashboardRightsForItem(string $itemtype, int $items_id, int $dashboard_id, int $rights): void + { + $criteria = [ + 'itemtype' => $itemtype, + 'items_id' => $items_id, + 'dashboard_id' => $dashboard_id, + ]; + + $itemright = new self(); + if ($itemright->getFromDBByCrit($criteria)) { + $itemright->update([ + 'id' => $itemright->getID(), + 'rights' => $rights, + ]); + } else { + $itemright->add($criteria + ['rights' => $rights]); + } + } + + /** + * Remove rights of a purged group or user. + * + * @param CommonDBTM $item + * + * @return void + */ + public static function cleanForItem(CommonDBTM $item): void + { + $itemright = new self(); + $itemright->deleteByCriteria([ + 'itemtype' => $item::class, + 'items_id' => $item->getID(), + ]); + } + + /** + * Install item rights database. + * + * @param Migration $migration + * + * @return void + */ + public static function install(Migration $migration) + { + /** @var DBmysql $DB */ + global $DB; + + $default_charset = DBConnection::getDefaultCharset(); + $default_collation = DBConnection::getDefaultCollation(); + $default_key_sign = DBConnection::getDefaultPrimaryKeySignOption(); + + $table = self::getTable(); + + if (!$DB->tableExists($table)) { + $migration->displayMessage("Installing $table"); + + $query = "CREATE TABLE IF NOT EXISTS `$table` ( + `id` int {$default_key_sign} NOT NULL AUTO_INCREMENT, + `itemtype` varchar(100) NOT NULL, + `items_id` int {$default_key_sign} NOT NULL, + `dashboard_id` int NOT NULL, + `rights` int NOT NULL, + PRIMARY KEY (`id`), + UNIQUE `unicity` (`itemtype`, `items_id`, `dashboard_id`), + KEY `dashboard_id` (`dashboard_id`) + ) ENGINE=InnoDB DEFAULT CHARSET={$default_charset} COLLATE={$default_collation} ROW_FORMAT=DYNAMIC;"; + $DB->doQuery($query); + } + } + + /** + * Uninstall item rights database. + * + * @return void + */ + public static function uninstall() + { + /** @var DBmysql $DB */ + global $DB; + + $DB->doQuery('DROP TABLE IF EXISTS `' . self::getTable() . '`'); + } +} diff --git a/setup.php b/setup.php index 8451ac2..4668bd1 100644 --- a/setup.php +++ b/setup.php @@ -78,7 +78,7 @@ function plugin_init_metabase() //display helpdesk menu if self-service and if is able to view at least one dashboard. if ( $_SESSION['glpiactiveprofile']['interface'] == 'helpdesk' - && PluginMetabaseProfileright::canProfileViewDashboards($_SESSION['glpiactiveprofile']['id']) + && PluginMetabaseDashboard::canCurrentUserViewDashboards() ) { $PLUGIN_HOOKS['helpdesk_menu_entry']['metabase'] = '/front/selfservice.php'; $PLUGIN_HOOKS['helpdesk_menu_entry_icon']['metabase'] = 'ti ti-chart-bar'; @@ -88,6 +88,13 @@ function plugin_init_metabase() // profile rights management Plugin::registerClass('PluginMetabaseProfileright', ['addtabon' => 'Profile']); + // group and user rights management + Plugin::registerClass('PluginMetabaseItemright', ['addtabon' => [Group::class, User::class]]); + $PLUGIN_HOOKS['item_purge']['metabase'] = [ + Group::class => [PluginMetabaseItemright::class, 'cleanForItem'], + User::class => [PluginMetabaseItemright::class, 'cleanForItem'], + ]; + // css & js $PLUGIN_HOOKS['add_css']['metabase'] = 'metabase.css'; $PLUGIN_HOOKS['add_javascript']['metabase'] = 'metabase.js'; diff --git a/templates/itemright.html.twig b/templates/itemright.html.twig new file mode 100644 index 0000000..3bdc5b4 --- /dev/null +++ b/templates/itemright.html.twig @@ -0,0 +1,76 @@ +{# + # ------------------------------------------------------------------------- + # Metabase plugin for GLPI + # ------------------------------------------------------------------------- + # + # LICENSE + # + # This file is part of Metabase. + # + # Metabase is free software; you can redistribute it and/or modify + # it under the terms of the GNU General Public License as published by + # the Free Software Foundation; either version 2 of the License, or + # (at your option) any later version. + # + # Metabase is distributed in the hope that it will be useful, + # but WITHOUT ANY WARRANTY; without even the implied warranty of + # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + # GNU General Public License for more details. + # + # You should have received a copy of the GNU General Public License + # along with Metabase. If not, see . + # ------------------------------------------------------------------------- + # @copyright Copyright (C) 2018-2023 by Metabase plugin team. + # @license GPLv2 https://www.gnu.org/licenses/gpl-2.0.html + # @link https://github.com/pluginsGLPI/metabase + # ------------------------------------------------------------------------- + #} + +{% import 'components/form/fields_macros.html.twig' as fields %} + +{% if no_dashboard %} +
+ {{ __('No dashboards found in Metabase.', 'metabase') }} +
+{% else %} +
+ {{ fields.hiddenField('itemtype', itemtype) }} + {{ fields.hiddenField('items_id', items_id) }} + + {{ fields.largeTitle(__('Rights management', 'metabase'), 'ti ti-lock') }} + + {% if can_update %} +
+ + +
+ {% endif %} + + {% for dashboard in dashboards %} + {{ fields.dropdownArrayField( + 'dashboard[' ~ dashboard.id ~ ']', + dashboard.rights, + right_choices, + dashboard.name, + { + disabled: not can_update, + } + ) }} + {% endfor %} + + {% if can_update %} +
+ +
+ {% endif %} +
+{% endif %}