From 6060bed4500d448aee2b361c5b33cc1d225f4c65 Mon Sep 17 00:00:00 2001 From: jk <98026751+jacobkarapatakis@users.noreply.github.com> Date: Thu, 4 Aug 2022 18:29:04 +0300 Subject: [PATCH 01/43] Install instructions for RHEL --- INSTALL/INSTALL-rhel.md | 160 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 160 insertions(+) create mode 100644 INSTALL/INSTALL-rhel.md diff --git a/INSTALL/INSTALL-rhel.md b/INSTALL/INSTALL-rhel.md new file mode 100644 index 0000000..5f44f83 --- /dev/null +++ b/INSTALL/INSTALL-rhel.md @@ -0,0 +1,160 @@ +# Installing Cerebrate on RedHat Enterprise Linux (RHEL 8) +>This installation instructions assume SELinux is enabled, and in Enforcing mode. +>and that you want to keep it that way :) +>You need to be root when running these commands. + +## Prerequisites +>Install needed packages: +```Shell +dnf install @httpd mariadb-server git @php unzip sqlite vim wget php-intl php-ldap php-mysqlnd php-pdo php-zip +``` +## Install composer +>Instructions taken from https://getcomposer.org/download/ +```PHP +cd /root +php -r "copy('https://getcomposer.org/installer', 'composer-setup.php');" +php -r "if (hash_file('sha384', 'composer-setup.php') === '55ce33d7678c5a611085589f1f3ddf8b3c52d662cd01d4ba75c0ee0459970c2200a51f492d557530c71c15d8dba01eae') { echo 'Installer verified'; } else { echo 'Installer corrupt'; unlink('composer-setup.php'); } echo PHP_EOL;" +php composer-setup.php --install-dir=/usr/bin --filename=composer +php -r "unlink('composer-setup.php');" +``` + +## Prepare MySQL for cerebrate +>Enable and start mysql database. Select a secure password for root user, delete test user and database. + +```Shell +systemctl start mariadb +systemctl enable mariadb +mysql_secure_installation +``` +### Create a new database, user and password for cerebrate +```Shell +mysql -u root -p +``` +```SQL +CREATE DATABASE cerebrate; +CREATE USER 'cerebrate'@'localhost' IDENTIFIED BY 'CHANGE_ME_PASSWORD'; +GRANT USAGE ON *.* to cerebrate@localhost; +GRANT ALL PRIVILEGES ON cerebrate.* to cerebrate@localhost; +FLUSH PRIVILEGES; +QUIT; +``` +## Allow ports through the firewall +```Shell +firewall-cmd --zone=public --add-service=http --permanent +firewall-cmd --zone=public --add-port=8001/tcp --permanent +``` +> reload firewall and show applied firewall rules +```Shell +firewall-cmd --reload +firewall-cmd --zone public --list-all +``` + +## Main Cerebrate Installation +>Steps to install Cerebrate on RHEL + +### Clone this repository +```Shell +mkdir /var/www/cerebrate +git clone https://github.com/cerebrate-project/cerebrate.git /var/www/cerebrate +``` + +### Run composer +```Shell +mkdir -p /var/www/.composer +chown -R apache.apache /var/www/.composer +chown -R apache.apache /var/www/cerebrate +cd /var/www/cerebrate +composer install +``` +>you will see a prompt: \ +>`Do you trust "cakephp/plugin-installer" to execurte code and wish to enable it now? (writes "allow-plugins" to composer.json) [y,n,d,?]` \ +>*repond with* `y` \ +>`Do you trust "dealerdirect/phpcodesniffer-composer-installer" to execute code and wish to enable it now? (writes "allow-plugins" to composer.json) [y,n,d,?]` \ +>*repond with* `y` + +### Create your local configuration and set the db credentials +```Shell +cp -a /var/www/cerebrate/config/app_local.example.php /var/www/cerebrate/config/app_local.php +cp -a /var/www/cerebrate/config/config.example.json /var/www/cerebrate/config/config.json +``` + +### Modify the Datasource -> default array's in file `app_local.php` +>Simply modify the `Datasources` section, to reflect your values for: username, password, and database +>fields, as configured in the above [#create-a-new-database-user-and-password-for-cerebrate](<#create-a-new-database-user-and-password-for-cerebrate>) +```Shell +vim /var/www/cerebrate/config/app_local.php +``` +```PHP + 'Datasources' => [ + 'default' => [ + 'host' => 'localhost', + 'username' => 'cerebrate', + 'password' => 'CHANGE_ME_PASSWORD', + 'database' => 'cerebrate', + ... +``` + +### Run the database schema migrations +```Shell +usermod -s /bin/bash apache + +chown -R apache.apache /var/www/.composer +chown -R apache.apache /var/www/cerebrate + +su apache <<'EOFi' +/var/www/cerebrate/bin/cake migrations migrate +/var/www/cerebrate/bin/cake migrations migrate -p tags +/var/www/cerebrate/bin/cake migrations migrate -p ADmad/SocialAuth +EOFi + +usermod -s /sbin/nologin apache +``` + + +### Clean cakephp caches +```Shell +rm /var/www/cerebrate/tmp/cache/models/* +rm /var/www/cerebrate/tmp/cache/persistent/* +``` + +### copy the Apache httpd template to the default apache configuration folder +> in our case we used apache to serve this website, NGINX could also be used. +```Shell +cp -v /var/www/cerebrate/INSTALL/cerebrate_apache_dev.conf /etc/httpd/conf.d/. +mkdir /var/log/apache2 +chown apache.root -R /var/log/apache2 +restorecon -Rv /etc/httpd/conf.d/* +restorecon -Rv /var/log/* +``` +### Make changes to the apache httpd site configuration file +>Edit the file `/etc/httpd/conf.d/cerebrate_apache_dev.conf` change the two references of port 8000 to 8001 +```Shell +vi /etc/httpd/conf.d/cerebrate_apache_dev.conf +``` +### Make changes to SELinux +>From the SELinux Manual page [services with non standard ports]() +>We need SELinux to allow httpd to connect to our custom port 8001/tcp +```SELinux Policy +semanage port -a -t http_port_t -p tcp 8001 +``` +>Change SELinux context for folder /var/www/cerebrate +```SELinux Policy +semanage fcontext -a -t httpd_sys_content_t "/var/www/cerebrate(/.*)?" +restorecon -Rv /var/www/cerebrate/ +chown apache.apache /var/www/cerebrate +``` + +## Apply changes/restart Apache httpd +>Look out for any errors during restart. +``` +systemctl enable httpd +systemctl restart httpd +``` + +## Point your browser to: http://localhost:8001 +> If everything worked, you should be able to log in using the default credentials below: + +``` +Username: admin +Password: Password1234 +``` From 98970dca2c79a848d1760af44681a340d529ab83 Mon Sep 17 00:00:00 2001 From: DocArmoryTech Date: Tue, 30 Aug 2022 20:19:57 +0100 Subject: [PATCH 02/43] Alignment links missing / When baseurl is configured in the UI as: `https://cerebrate.example.com/` (`App.baseurl` is `https:\/\/cerebrate.example.com\/` in config.json a `/` between the `$baseurl` and the url path appears to be missing: - When viewing the index of all individuals (/individuals/index), links to the Organisations in their Alignments are missing a `/` - When viewing an organisation that includes individuals with an alignment, links to the Individuals are missing a `/` --- .../element/genericElements/IndexTable/Fields/alignments.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/templates/element/genericElements/IndexTable/Fields/alignments.php b/templates/element/genericElements/IndexTable/Fields/alignments.php index 875c480..15c29e1 100644 --- a/templates/element/genericElements/IndexTable/Fields/alignments.php +++ b/templates/element/genericElements/IndexTable/Fields/alignments.php @@ -8,7 +8,7 @@ if ($field['scope'] === 'individuals') { '
%s @ %s
', h($alignment['type']), sprintf( - '%s', + '%s', $baseurl, h($alignment['organisation']['id']), h($alignment['organisation']['name']) @@ -28,7 +28,7 @@ if ($field['scope'] === 'individuals') { '
[%s] %s
', h($alignment['type']), sprintf( - '%s', + '%s', $baseurl, h($alignment['individual']['id']), h($alignment['individual']['email']) From 4bcccf029c493d5296b672e17a9e9ab7f09f9ede Mon Sep 17 00:00:00 2001 From: DocArmoryTech Date: Tue, 30 Aug 2022 20:27:11 +0100 Subject: [PATCH 03/43] Alignment links missing / When baseurl is configured in the UI as: `https://cerebrate.example.com/` (`App.baseurl` is `https:\/\/cerebrate.example.com\/` in config.json a `/` between the `$baseurl` and the url path appears to be missing: - When viewing an individual and their alignments (/individuals/view/123), links to the Organisations in their Alignments are missing a `/` - When viewing an organisation, links to the individuals with an alignment to the organisation are missing a `/` --- .../genericElements/SingleViews/Fields/alignmentField.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/templates/element/genericElements/SingleViews/Fields/alignmentField.php b/templates/element/genericElements/SingleViews/Fields/alignmentField.php index ffd605a..1832ec0 100644 --- a/templates/element/genericElements/SingleViews/Fields/alignmentField.php +++ b/templates/element/genericElements/SingleViews/Fields/alignmentField.php @@ -14,7 +14,7 @@ if ($field['scope'] === 'individuals') { '
%s @ %s
', h($alignment['type']), sprintf( - '%s', + '%s', $baseurl, h($alignment['organisation']['id']), h($alignment['organisation']['name']) @@ -34,7 +34,7 @@ if ($field['scope'] === 'individuals') { '
[%s] %s
', h($alignment['type']), sprintf( - '%s', + '%s', $baseurl, h($alignment['individual']['id']), h($alignment['individual']['email']) From 84537c52f9ff5e4fbf893e4adc0c918735788c7c Mon Sep 17 00:00:00 2001 From: iglocska Date: Tue, 25 Oct 2022 10:57:18 +0200 Subject: [PATCH 04/43] new: [user enrollment] send keycloak welcome email to users when enrolled --- src/Model/Behavior/AuthKeycloakBehavior.php | 22 ++++++++++++++++++--- src/Model/Table/UsersTable.php | 2 +- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/src/Model/Behavior/AuthKeycloakBehavior.php b/src/Model/Behavior/AuthKeycloakBehavior.php index 4d17b61..0519656 100644 --- a/src/Model/Behavior/AuthKeycloakBehavior.php +++ b/src/Model/Behavior/AuthKeycloakBehavior.php @@ -115,7 +115,8 @@ class AuthKeycloakBehavior extends Behavior foreach ($roles as $role) { $rolesParsed[$role['name']] = $role['id']; } - if (!$this->createUser($user, $clientId, $rolesParsed)) { + $newUserId = $this->createUser($user, $clientId, $rolesParsed); + if (!$newUserId) { $logChange = [ 'username' => $user['username'], 'individual_id' => $user['individual']['id'], @@ -141,6 +142,21 @@ class AuthKeycloakBehavior extends Behavior 'model_title' => __('Successful Keycloak enrollment for user {0}', $user['username']), 'changed' => $logChange ]); + $response = $this->restApiRequest( + '%s/admin/realms/%s/users/' . urlencode($newUserId) . '/execute-actions-email', + ['UPDATE_PASSWORD'], + 'put' + ); + if (!$response->isOk()) { + $responseBody = json_decode($response->getStringBody(), true); + $this->_table->auditLogs()->insert([ + 'request_action' => 'keycloakWelcomeEmail', + 'model' => 'User', + 'model_id' => 0, + 'model_title' => __('Failed to send welcome mail to user ({0}) in keycloak', $user['username']), + 'changed' => ['error' => empty($responseBody['errorMessage']) ? 'Unknown error.' : $responseBody['errorMessage']] + ]); + } } return true; } @@ -373,7 +389,7 @@ class AuthKeycloakBehavior extends Behavior return false; } - private function createUser(array $user, string $clientId, array $rolesParsed): bool + private function createUser(array $user, string $clientId, array $rolesParsed): string|bool { $newUser = [ 'username' => $user['username'], @@ -409,7 +425,7 @@ class AuthKeycloakBehavior extends Behavior } $user['id'] = $users[0]['id']; $this->assignRolesToUser($user, $rolesParsed, $clientId); - return true; + return $user['id']; } private function assignRolesToUser(array $user, array $rolesParsed, string $clientId): bool diff --git a/src/Model/Table/UsersTable.php b/src/Model/Table/UsersTable.php index 789c9aa..254a743 100644 --- a/src/Model/Table/UsersTable.php +++ b/src/Model/Table/UsersTable.php @@ -221,7 +221,7 @@ class UsersTable extends AppTable { if (!empty(Configure::read('keycloak'))) { $success = $this->handleUserUpdate($user); - return $success; + //return $success !== false; } return true; } From 9a8372be12df25b7075dc66ebf624bfac882d17f Mon Sep 17 00:00:00 2001 From: iglocska Date: Tue, 25 Oct 2022 11:01:23 +0200 Subject: [PATCH 05/43] fix: [return type] validation removed --- src/Model/Behavior/AuthKeycloakBehavior.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Model/Behavior/AuthKeycloakBehavior.php b/src/Model/Behavior/AuthKeycloakBehavior.php index 0519656..af96032 100644 --- a/src/Model/Behavior/AuthKeycloakBehavior.php +++ b/src/Model/Behavior/AuthKeycloakBehavior.php @@ -389,7 +389,7 @@ class AuthKeycloakBehavior extends Behavior return false; } - private function createUser(array $user, string $clientId, array $rolesParsed): string|bool + private function createUser(array $user, string $clientId, array $rolesParsed) { $newUser = [ 'username' => $user['username'], From 9c41fd548fcab755b7b29096e8170d451dcc3223 Mon Sep 17 00:00:00 2001 From: iglocska Date: Tue, 25 Oct 2022 15:08:41 +0200 Subject: [PATCH 06/43] fix: [auth] added keycloak logout --- src/Controller/UsersController.php | 3 +++ src/Model/Behavior/AuthKeycloakBehavior.php | 12 ++++++++++++ 2 files changed, 15 insertions(+) diff --git a/src/Controller/UsersController.php b/src/Controller/UsersController.php index b0d35d2..9cf35af 100644 --- a/src/Controller/UsersController.php +++ b/src/Controller/UsersController.php @@ -354,6 +354,9 @@ class UsersController extends AppController ]); $this->Authentication->logout(); $this->Flash->success(__('Goodbye.')); + if (Configure::read('keycloak.enabled')) { + $this->redirect($this->Users->keyCloaklogout()); + } return $this->redirect(\Cake\Routing\Router::url('/users/login')); } } diff --git a/src/Model/Behavior/AuthKeycloakBehavior.php b/src/Model/Behavior/AuthKeycloakBehavior.php index af96032..0505c58 100644 --- a/src/Model/Behavior/AuthKeycloakBehavior.php +++ b/src/Model/Behavior/AuthKeycloakBehavior.php @@ -185,6 +185,18 @@ class AuthKeycloakBehavior extends Behavior return !empty($changes); } + public function keyCloaklogout(): string + { + $keycloakConfig = Configure::read('keycloak'); + $logoutUrl = sprintf( + '%s/realms/%s/protocol/openid-connect/logout?redirect_uri=%s', + $keycloakConfig['provider']['baseUrl'], + $keycloakConfig['provider']['realm'], + urlencode(Configure::read('App.fullBaseUrl')) + ); + return $logoutUrl; + } + private function getAdminAccessToken() { $keycloakConfig = Configure::read('keycloak'); From 2f4b6ed2fffb6f77b70624ff8fa04146528ab7e8 Mon Sep 17 00:00:00 2001 From: iglocska Date: Mon, 31 Oct 2022 11:31:38 +0100 Subject: [PATCH 07/43] chg: [keycloak] integration rework - switch to the use of attributes - several minor fixes --- src/Controller/OrganisationsController.php | 7 + src/Controller/UsersController.php | 5 + src/Model/Behavior/AuthKeycloakBehavior.php | 252 ++++---------------- 3 files changed, 55 insertions(+), 209 deletions(-) diff --git a/src/Controller/OrganisationsController.php b/src/Controller/OrganisationsController.php index 3591a68..24fdd27 100644 --- a/src/Controller/OrganisationsController.php +++ b/src/Controller/OrganisationsController.php @@ -99,6 +99,13 @@ class OrganisationsController extends AppController public function edit($id) { + $currentUser = $this->ACL->getUser(); + if ( + !($currentUser['Organisation']['id'] == $id && $currentUser['Role']['perm_org_admin']) && + !$currentUser['Role']['perm_admin'] + ) { + throw new MethodNotAllowedException(__('You cannot modify that organisation.')); + } $this->CRUD->edit($id); $responsePayload = $this->CRUD->getResponsePayload(); if (!empty($responsePayload)) { diff --git a/src/Controller/UsersController.php b/src/Controller/UsersController.php index 9cf35af..a868820 100644 --- a/src/Controller/UsersController.php +++ b/src/Controller/UsersController.php @@ -413,4 +413,9 @@ class UsersController extends AppController } $this->viewBuilder()->setLayout('login'); } + + public function test() + { + + } } diff --git a/src/Model/Behavior/AuthKeycloakBehavior.php b/src/Model/Behavior/AuthKeycloakBehavior.php index 0505c58..08f35fe 100644 --- a/src/Model/Behavior/AuthKeycloakBehavior.php +++ b/src/Model/Behavior/AuthKeycloakBehavior.php @@ -18,7 +18,7 @@ use Cake\Http\Exception\NotFoundException; class AuthKeycloakBehavior extends Behavior { - +/* public function getUser(EntityInterface $profile, Session $session) { $userId = $session->read('Auth.User.id'); @@ -35,7 +35,7 @@ class AuthKeycloakBehavior extends Behavior return $user; } - +*/ private function extractProfileData($profile_payload) { $mapping = Configure::read('keycloak.mapping'); @@ -110,12 +110,7 @@ class AuthKeycloakBehavior extends Behavior )->first() ]; $clientId = $this->getClientId(); - $roles = $this->getAllRoles($clientId); - $rolesParsed = []; - foreach ($roles as $role) { - $rolesParsed[$role['name']] = $role['id']; - } - $newUserId = $this->createUser($user, $clientId, $rolesParsed); + $newUserId = $this->createUser($user, $clientId); if (!$newUserId) { $logChange = [ 'username' => $user['username'], @@ -165,9 +160,9 @@ class AuthKeycloakBehavior extends Behavior * handleUserUpdate * * @param \App\Model\Entity\User $user - * @return boolean If the update was a success + * @return array Containing changes if successful */ - public function handleUserUpdate(\App\Model\Entity\User $user): bool + public function handleUserUpdate(\App\Model\Entity\User $user): array { $user['individual'] = $this->_table->Individuals->find()->where([ 'id' => $user['individual_id'] @@ -181,8 +176,8 @@ class AuthKeycloakBehavior extends Behavior $users = [$user->toArray()]; $clientId = $this->getClientId(); - $changes = $this->syncUsers($users, $clientId); - return !empty($changes); + $changes = $this->syncUser($users, $clientId); + return $changes; } public function keyCloaklogout(): string @@ -237,8 +232,6 @@ class AuthKeycloakBehavior extends Behavior public function syncWithKeycloak(): array { $results = []; - $data['Roles'] = $this->_table->Roles->find()->disableHydration()->toArray(); - $data['Organisations'] = $this->_table->Organisations->find()->disableHydration()->toArray(); $data['Users'] = $this->_table->find()->contain(['Individuals', 'Organisations', 'Roles'])->select( [ 'id', @@ -256,86 +249,15 @@ class AuthKeycloakBehavior extends Behavior ] )->disableHydration()->toArray(); $clientId = $this->getClientId(); - $results = []; - $results['roles'] = $this->syncRoles(Hash::extract($data['Roles'], '{n}.name'), $clientId, 'Role'); - $results['organisations'] = $this->syncRoles(Hash::extract($data['Organisations'], '{n}.name'), $clientId, 'Organisation'); - $results['users'] = $this->syncUsers($data['Users'], $clientId); - return $results; + return $this->syncUsers($data['Users'], $clientId); } - private function syncRoles(array $roles, string $clientId, string $scope = 'Role'): int + private function syncUsers(array $users, $clientId): array { - $keycloakRoles = $this->getAllRoles($clientId); - $keycloakRolesParsed = Hash::extract($keycloakRoles, '{n}.name'); - $scopeString = $scope . ':'; - $modified = 0; - foreach ($roles as $role) { - if (!in_array($scopeString . $role, $keycloakRolesParsed)) { - $roleToPush = [ - 'name' => $scopeString . $role, - 'clientRole' => true - ]; - $url = '%s/admin/realms/%s/clients/' . $clientId . '/roles'; - $response = $this->restApiRequest($url, $roleToPush, 'post'); - if (!$response->isOk()) { - $this->_table->auditLogs()->insert([ - 'request_action' => 'keycloakCreateRole', - 'model' => 'User', - 'model_id' => 0, - 'model_title' => __('Failed to create role ({0}) in keycloak', $scopeString . $role), - 'changed' => [ - 'code' => $response->getStatusCode(), - 'error_body' => $response->getStringBody() - ] - ]); - } - $modified += 1; - } - $keycloakRolesParsed = array_diff($keycloakRolesParsed, [$scopeString . $role]); - } - foreach ($keycloakRolesParsed as $roleToRemove) { - if (substr($roleToRemove, 0, strlen($scopeString)) === $scopeString) { - $url = '%s/admin/realms/%s/clients/' . $clientId . '/roles/' . $roleToRemove; - $response = $this->restApiRequest($url, [], 'delete'); - if (!$response->isOk()) { - $this->_table->auditLogs()->insert([ - 'request_action' => 'keycloakRemoveRole', - 'model' => 'User', - 'model_id' => 0, - 'model_title' => __('Failed to remove role ({0}) in keycloak', $roleToRemove), - 'changed' => [ - 'code' => $response->getStatusCode(), - 'error_body' => $response->getStringBody() - ] - ]); - } - $modified += 1; - } - } - return $modified; - } - - private function getAllRoles(string $clientId): array - { - $response = $this->restApiRequest('%s/admin/realms/%s/clients/' . $clientId . '/roles', [], 'get'); - return json_decode($response->getStringBody(), true); - } - - private function syncUsers(array $users, $clientId, $roles = null): int - { - if ($roles === null) { - $roles = $this->getAllRoles($clientId); - } - $rolesParsed = []; - foreach ($roles as $role) { - $rolesParsed[$role['name']] = $role['id']; - } $response = $this->restApiRequest('%s/admin/realms/%s/users', [], 'get'); $keycloakUsers = json_decode($response->getStringBody(), true); $keycloakUsersParsed = []; foreach ($keycloakUsers as $u) { - $response = $this->restApiRequest('%s/admin/realms/%s/users/' . $u['id'] . '/role-mappings/clients/' . $clientId, [], 'get'); - $roleMappings = json_decode($response->getStringBody(), true); $keycloakUsersParsed[$u['username']] = [ 'id' => $u['id'], 'username' => $u['username'], @@ -343,26 +265,28 @@ class AuthKeycloakBehavior extends Behavior 'firstName' => $u['firstName'], 'lastName' => $u['lastName'], 'email' => $u['email'], - 'roles' => $roleMappings + 'attributes' => [ + 'role_name' => $u['attributes']['role_name'][0] ?? '', + 'role_uuid' => $u['attributes']['role_uuid'][0] ?? '', + 'org_uuid' => $u['attributes']['org_uuid'][0] ?? '', + 'org_name' => $u['attributes']['org_name'][0] ?? '' + ] ]; } - $changes = 0; + $changes = [ + 'created' => [], + 'modified' => [], + ]; foreach ($users as &$user) { $changed = false; if (empty($keycloakUsersParsed[$user['username']])) { - if ($this->createUser($user, $clientId, $rolesParsed)) { - $changes = true; + if ($this->createUser($user, $clientId)) { + $changes['created'][] = $user['username']; } } else { if ($this->checkAndUpdateUser($keycloakUsersParsed[$user['username']], $user)) { - $changes = true; + $changes['modified'][] = $user['username']; } - if ($this->checkAndUpdateUserRoles($keycloakUsersParsed[$user['username']], $user, $clientId, $rolesParsed)) { - $changes = true; - } - } - if ($changed) { - $changes += 1; } } return $changes; @@ -374,14 +298,25 @@ class AuthKeycloakBehavior extends Behavior $keycloakUser['enabled'] == $user['disabled'] || $keycloakUser['firstName'] !== $user['individual']['first_name'] || $keycloakUser['lastName'] !== $user['individual']['last_name'] || - $keycloakUser['email'] !== $user['individual']['email'] + $keycloakUser['email'] !== $user['individual']['email'] || + (empty($keycloakUser['attributes']['role_name']) || $keycloakUser['attributes']['role_name'] !== $user['role']['name']) || + (empty($keycloakUser['attributes']['role_uuid']) || $keycloakUser['attributes']['role_uuid'] !== $user['role']['uuid']) || + (empty($keycloakUser['attributes']['org_name']) || $keycloakUser['attributes']['org_name'] !== $user['organisation']['name']) || + (empty($keycloakUser['attributes']['org_uuid']) || $keycloakUser['attributes']['org_uuid'] !== $user['organisation']['uuid']) ) { $change = [ 'enabled' => !$user['disabled'], 'firstName' => $user['individual']['first_name'], 'lastName' => $user['individual']['last_name'], 'email' => $user['individual']['email'], + 'attributes' => [ + 'role_name' => $user['role']['name'], + 'role_uuid' => $user['role']['uuid'], + 'org_name' => $user['organisation']['name'], + 'org_uuid' => $user['organisation']['uuid'] + ] ]; + debug($change); $response = $this->restApiRequest('%s/admin/realms/%s/users/' . $keycloakUser['id'], $change, 'put'); if (!$response->isOk()) { $this->_table->auditLogs()->insert([ @@ -401,15 +336,22 @@ class AuthKeycloakBehavior extends Behavior return false; } - private function createUser(array $user, string $clientId, array $rolesParsed) + private function createUser(array $user, string $clientId) { $newUser = [ 'username' => $user['username'], 'enabled' => !$user['disabled'], 'firstName' => $user['individual']['first_name'], 'lastName' => $user['individual']['last_name'], - 'email' => $user['individual']['email'] + 'email' => $user['individual']['email'], + 'attributes' => [ + 'role_name' => $user['role']['name'], + 'role_uuid' => $user['role']['uuid'], + 'org_name' => $user['organisation']['name'], + 'org_uuid' => $user['organisation']['uuid'] + ] ]; + debug($newUser); $response = $this->restApiRequest('%s/admin/realms/%s/users', $newUser, 'post'); if (!$response->isOk()) { $this->_table->auditLogs()->insert([ @@ -436,117 +378,9 @@ class AuthKeycloakBehavior extends Behavior $users[0]['id'] = $users[0]['id'][0]; } $user['id'] = $users[0]['id']; - $this->assignRolesToUser($user, $rolesParsed, $clientId); return $user['id']; } - private function assignRolesToUser(array $user, array $rolesParsed, string $clientId): bool - { - $roles = [ - [ - 'id' => $rolesParsed['Role:' . $user['role']['name']], - 'name' => 'Role:' . $user['role']['name'], - 'clientRole' => true, - 'containerId' => $clientId - ], - [ - 'id' => $rolesParsed['Organisation:' . $user['organisation']['name']], - 'name' => 'Organisation:' . $user['organisation']['name'], - 'clientRole' => true, - 'containerId' => $clientId - ] - ]; - $response = $this->restApiRequest('%s/admin/realms/%s/users/' . $user['id'] . '/role-mappings/clients/' . $clientId, $roles, 'post'); - if (!$response->isOk()) { - $this->_table->auditLogs()->insert([ - 'request_action' => 'keycloakAssignRoles', - 'model' => 'User', - 'model_id' => 0, - 'model_title' => __('Failed to create assign role ({0}) in keycloak to user {1}', $user['role']['name'], $user['username']), - 'changed' => [ - 'code' => $response->getStatusCode(), - 'error_body' => $response->getStringBody() - ] - ]); - } - return true; - } - - private function checkAndUpdateUserRoles(array $keycloakUser, array $user, string $clientId, array $rolesParsed): bool - { - $assignedRoles = $this->restApiRequest('%s/admin/realms/%s/users/' . $keycloakUser['id'] . '/role-mappings/clients/' . $clientId, [], 'get'); - $assignedRoles = json_decode($assignedRoles->getStringBody(), true); - $keycloakUserRoles = Hash::extract($assignedRoles, '{n}.name'); - $assignedRolesParsed = []; - foreach ($assignedRoles as $k => $v) { - $assignedRolesParsed[$v['name']] = $v; - } - $userRoles = [ - 'Organisation:' . $user['organisation']['name'] => [ - 'id' => $rolesParsed['Organisation:' . $user['organisation']['name']], - 'name' => 'Organisation:' . $user['organisation']['name'], - 'clientRole' => true, - 'containerId' => $clientId - ], - 'Role:' . $user['role']['name'] => [ - 'id' => $rolesParsed['Role:' . $user['role']['name']], - 'name' => 'Role:' . $user['role']['name'], - 'clientRole' => true, - 'containerId' => $clientId - ] - ]; - $toAdd = array_diff(array_keys($userRoles), $keycloakUserRoles); - $toRemove = array_diff($keycloakUserRoles, array_keys($userRoles)); - $changed = false; - foreach ($toRemove as $k => $role) { - if (substr($role, 0, strlen('Organisation:')) !== 'Organisation:' && substr($role, 0, strlen('Role:')) !== 'Role:') { - unset($toRemove[$k]); - } else { - $toRemove[$k] = $assignedRolesParsed[$role]; - } - } - if (!empty($toRemove)) { - $toRemove = array_values($toRemove); - $response = $this->restApiRequest('%s/admin/realms/%s/users/' . $keycloakUser['id'] . '/role-mappings/clients/' . $clientId, $toRemove, 'delete'); - if (!$response->isOk()) { - $this->_table->auditLogs()->insert([ - 'request_action' => 'keycloakDetachRole', - 'model' => 'User', - 'model_id' => 0, - 'model_title' => __('Failed to detach role ({0}) in keycloak from user {1}', $user['role']['name'], $user['username']), - 'changed' => [ - 'code' => $response->getStatusCode(), - 'error_body' => $response->getStringBody() - ] - ]); - } else { - $changed = true; - } - } - foreach ($toAdd as $k => $name) { - $toAdd[$k] = $userRoles[$name]; - } - if (!empty($toAdd)) { - $toAdd = array_values($toAdd); - $response = $this->restApiRequest('%s/admin/realms/%s/users/' . $keycloakUser['id'] . '/role-mappings/clients/' . $clientId, $toAdd, 'post'); - if (!$response->isOk()) { - $this->_table->auditLogs()->insert([ - 'request_action' => 'keycloakAttachRoles', - 'model' => 'User', - 'model_id' => 0, - 'model_title' => __('Failed to attach role ({0}) in keycloak to user {1}', $user['role']['name'], $user['username']), - 'changed' => [ - 'code' => $response->getStatusCode(), - 'error_body' => $response->getStringBody() - ] - ]); - } else { - $changed = true; - } - } - return $changed; - } - private function urlencodeEscapeForSprintf(string $input): string { return str_replace('%', '%%', $input); From 3bf52c701f33f749553740607313ca95845cfd1b Mon Sep 17 00:00:00 2001 From: iglocska Date: Mon, 31 Oct 2022 11:32:15 +0100 Subject: [PATCH 08/43] chg: [keycloak] sync script updated --- src/Command/KeycloakSyncCommand.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Command/KeycloakSyncCommand.php b/src/Command/KeycloakSyncCommand.php index efe14e6..6a82f99 100644 --- a/src/Command/KeycloakSyncCommand.php +++ b/src/Command/KeycloakSyncCommand.php @@ -16,10 +16,10 @@ class KeycloakSyncCommand extends Command $this->loadModel('Users'); $results = $this->fetchTable()->syncWithKeycloak(); $tableData = [ - ['Changes to', 'Count'] + ['Modification type', 'Count', 'Affected users'] ]; foreach ($results as $k => $v) { - $tableData[] = [$k, '' . $v . '']; + $tableData[] = [$k, '' . count($v) . '', '' . implode(', ', $v) . '']; } $io->out(__('Sync done. See the results below.')); $io->helper('Table')->output($tableData); From 2a31e397623624c5b10e95767e0d6d77f23bac1a Mon Sep 17 00:00:00 2001 From: iglocska Date: Mon, 31 Oct 2022 13:26:12 +0100 Subject: [PATCH 09/43] new: [keycloak] automatically set mappings --- src/Controller/UsersController.php | 1 + src/Model/Behavior/AuthKeycloakBehavior.php | 56 ++++++++++++++++++- .../CerebrateSettingsProvider.php | 8 +++ 3 files changed, 62 insertions(+), 3 deletions(-) diff --git a/src/Controller/UsersController.php b/src/Controller/UsersController.php index a868820..426f4a3 100644 --- a/src/Controller/UsersController.php +++ b/src/Controller/UsersController.php @@ -16,6 +16,7 @@ class UsersController extends AppController public function index() { + $this->Users->updateMappers(); $currentUser = $this->ACL->getUser(); $conditions = []; if (empty($currentUser['role']['perm_admin'])) { diff --git a/src/Model/Behavior/AuthKeycloakBehavior.php b/src/Model/Behavior/AuthKeycloakBehavior.php index 08f35fe..8e86f74 100644 --- a/src/Model/Behavior/AuthKeycloakBehavior.php +++ b/src/Model/Behavior/AuthKeycloakBehavior.php @@ -176,7 +176,7 @@ class AuthKeycloakBehavior extends Behavior $users = [$user->toArray()]; $clientId = $this->getClientId(); - $changes = $this->syncUser($users, $clientId); + $changes = $this->syncUsers($users, $clientId); return $changes; } @@ -231,6 +231,7 @@ class AuthKeycloakBehavior extends Behavior public function syncWithKeycloak(): array { + $this->updateMappers(); $results = []; $data['Users'] = $this->_table->find()->contain(['Individuals', 'Organisations', 'Roles'])->select( [ @@ -316,7 +317,6 @@ class AuthKeycloakBehavior extends Behavior 'org_uuid' => $user['organisation']['uuid'] ] ]; - debug($change); $response = $this->restApiRequest('%s/admin/realms/%s/users/' . $keycloakUser['id'], $change, 'put'); if (!$response->isOk()) { $this->_table->auditLogs()->insert([ @@ -351,7 +351,6 @@ class AuthKeycloakBehavior extends Behavior 'org_uuid' => $user['organisation']['uuid'] ] ]; - debug($newUser); $response = $this->restApiRequest('%s/admin/realms/%s/users', $newUser, 'post'); if (!$response->isOk()) { $this->_table->auditLogs()->insert([ @@ -385,4 +384,55 @@ class AuthKeycloakBehavior extends Behavior { return str_replace('%', '%%', $input); } + + public function updateMappers(): bool + { + $clientId = $this->getClientId(); + $response = $this->restApiRequest('%s/admin/realms/%s/clients/' . $clientId . '/protocol-mappers/models?protocolMapper=oidc-usermodel-attribute-mapper', [], 'get'); + if ($response->isOk()) { + $mappers = json_decode($response->getStringBody(), true); + } else { + return false; + } + $enabledMappers = []; + $defaultMappers = [ + 'org_name' => 0, + 'org_uuid' => 0, + 'role_name' => 0, + 'role_uuid' => 0 + ]; + $mappersToEnable = explode(',', Configure::read('keycloak.user_meta_mapping')); + foreach ($mappers as $mapper) { + if ($mapper['protocolMapper'] !== 'oidc-usermodel-attribute-mapper') { + continue; + } + if (in_array($mapper['name'], array_keys($defaultMappers))) { + $defaultMappers[$mapper['name']] = 1; + continue; + } + $enabledMappers[$mapper['name']] = $mapper; + } + $payload = []; + foreach ($mappersToEnable as $mapperToEnable) { + $payload[] = [ + 'protocol' => 'openid-connect', + 'name' => $mapperToEnable, + 'protocolMapper' => 'oidc-usermodel-attribute-mapper', + 'config' => [ + 'id.token.claim' => true, + 'access.token.claim' => true, + 'userinfo.token.claim' => true, + 'user.attribute' => $mapperToEnable, + 'claim.name' => $mapperToEnable + ] + ]; + } + if (!empty($payload)) { + $response = $this->restApiRequest('%s/admin/realms/%s/clients/' . $clientId . '/protocol-mappers/add-models', $payload, 'post'); + if (!$response->isOk()) { + return false; + } + } + return true; + } } diff --git a/src/Model/Table/SettingProviders/CerebrateSettingsProvider.php b/src/Model/Table/SettingProviders/CerebrateSettingsProvider.php index 801885c..421f6b5 100644 --- a/src/Model/Table/SettingProviders/CerebrateSettingsProvider.php +++ b/src/Model/Table/SettingProviders/CerebrateSettingsProvider.php @@ -284,6 +284,14 @@ class CerebrateSettingsProvider extends BaseSettingsProvider 'description' => __('family_name mapped name in keycloak'), 'dependsOn' => 'keycloak.enabled' ], + 'keycloak.user_meta_mapping' => [ + 'name' => 'User Meta-field attribute mapping', + 'type' => 'string', + 'severity' => 'info', + 'default' => '', + 'description' => __('List of user metafields to push to keycloak as attributes. When using multiple templates, the attribute names have to be unique. Expects a comma separated list.'), + 'dependsOn' => 'keycloak.enabled' + ] ] ] ], From ce6575cfb67ee5e607992fe145753d57842fb2d8 Mon Sep 17 00:00:00 2001 From: iglocska Date: Mon, 31 Oct 2022 13:26:44 +0100 Subject: [PATCH 10/43] chg: [User] Entity - added rearrangeForAPI() to rearrange meta fields --- src/Model/Entity/User.php | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/Model/Entity/User.php b/src/Model/Entity/User.php index 0e685f5..cf8fb6e 100644 --- a/src/Model/Entity/User.php +++ b/src/Model/Entity/User.php @@ -48,4 +48,14 @@ class User extends AppModel return (new DefaultPasswordHasher())->hash($password); } } + + public function rearrangeForAPI(): void + { + if (!empty($this->meta_fields)) { + $this->rearrangeMetaFields(); + } + if (!empty($this->MetaTemplates)) { + unset($this->MetaTemplates); + } + } } From 5ec0471ccec9beb3d3e60e403005f873febbd2bf Mon Sep 17 00:00:00 2001 From: iglocska Date: Mon, 31 Oct 2022 13:36:53 +0100 Subject: [PATCH 11/43] chg: [keycloak mapper] also pushes default mappings if they don't exist --- src/Model/Behavior/AuthKeycloakBehavior.php | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/Model/Behavior/AuthKeycloakBehavior.php b/src/Model/Behavior/AuthKeycloakBehavior.php index 8e86f74..b3300a0 100644 --- a/src/Model/Behavior/AuthKeycloakBehavior.php +++ b/src/Model/Behavior/AuthKeycloakBehavior.php @@ -427,6 +427,22 @@ class AuthKeycloakBehavior extends Behavior ] ]; } + foreach ($defaultMappers as $defaultMapper => $enabled) { + if (!$enabled) { + $payload[] = [ + 'protocol' => 'openid-connect', + 'name' => $defaultMapper, + 'protocolMapper' => 'oidc-usermodel-attribute-mapper', + 'config' => [ + 'id.token.claim' => true, + 'access.token.claim' => true, + 'userinfo.token.claim' => true, + 'user.attribute' => $defaultMapper, + 'claim.name' => $defaultMapper + ] + ]; + } + } if (!empty($payload)) { $response = $this->restApiRequest('%s/admin/realms/%s/clients/' . $clientId . '/protocol-mappers/add-models', $payload, 'post'); if (!$response->isOk()) { From 8dbbb21dffb5857f09629e02a3453dc74800d2dd Mon Sep 17 00:00:00 2001 From: iglocska Date: Mon, 31 Oct 2022 13:38:31 +0100 Subject: [PATCH 12/43] chg: [users] add metafields behaviour --- src/Model/Table/UsersTable.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Model/Table/UsersTable.php b/src/Model/Table/UsersTable.php index 254a743..2cfe8b2 100644 --- a/src/Model/Table/UsersTable.php +++ b/src/Model/Table/UsersTable.php @@ -23,6 +23,7 @@ class UsersTable extends AppTable parent::initialize($config); $this->addBehavior('Timestamp'); $this->addBehavior('UUID'); + $this->addBehavior('MetaFields'); $this->addBehavior('AuditLog'); $this->initAuthBehaviors(); $this->belongsTo( From 675b6f29e94b25cfb2fce8026948be6cfdd2ad69 Mon Sep 17 00:00:00 2001 From: iglocska Date: Mon, 31 Oct 2022 13:40:49 +0100 Subject: [PATCH 13/43] new: [cnw] meta template for permissions - first revision --- .../csirt_network_permissions.json | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 libraries/default/meta_fields/csirt_network_permissions.json diff --git a/libraries/default/meta_fields/csirt_network_permissions.json b/libraries/default/meta_fields/csirt_network_permissions.json new file mode 100644 index 0000000..c0eccde --- /dev/null +++ b/libraries/default/meta_fields/csirt_network_permissions.json @@ -0,0 +1,28 @@ +{ + "name": "CSIRT network permissions", + "namespace": "csn", + "description": "Template of additional tooling permissions for the CSIRT network", + "version": 1, + "scope": "user", + "uuid": "447ded8b-314b-41c7-a913-4ce32535b28d", + "source": "CSIRT network", + "metaFields": [ + { + "field": "perm_mattermost", + "type": "boolean" + }, + { + "field": "perm_bigbluebutton", + "type": "boolean" + }, + { + "field": "perm_sharepoint", + "type": "boolean" + }, + { + "field": "perm_misp", + "type": "boolean" + } + ] +} + From 11510ea28f1d63fb3f63076b2323e57d84520545 Mon Sep 17 00:00:00 2001 From: iglocska Date: Mon, 31 Oct 2022 14:42:58 +0100 Subject: [PATCH 14/43] new: [individual] editing enabled for org admins - requires that a user exist for the given individual --- src/Controller/Component/ACLComponent.php | 2 +- src/Controller/IndividualsController.php | 15 +++++++++++++++ src/Model/Table/IndividualsTable.php | 11 +++++++++++ templates/Individuals/add.php | 2 +- templates/Individuals/index.php | 17 +++++++++++++++-- 5 files changed, 43 insertions(+), 4 deletions(-) diff --git a/src/Controller/Component/ACLComponent.php b/src/Controller/Component/ACLComponent.php index f296030..095c548 100644 --- a/src/Controller/Component/ACLComponent.php +++ b/src/Controller/Component/ACLComponent.php @@ -87,7 +87,7 @@ class ACLComponent extends Component 'Individuals' => [ 'add' => ['perm_admin'], 'delete' => ['perm_admin'], - 'edit' => ['perm_admin'], + 'edit' => ['perm_admin', 'perm_org_admin'], 'filtering' => ['*'], 'index' => ['*'], 'tag' => ['perm_tagger'], diff --git a/src/Controller/IndividualsController.php b/src/Controller/IndividualsController.php index cc5a8fa..d88d297 100644 --- a/src/Controller/IndividualsController.php +++ b/src/Controller/IndividualsController.php @@ -20,6 +20,8 @@ class IndividualsController extends AppController public function index() { + $currentUser = $this->ACL->getUser(); + $orgAdmin = !$currentUser['role']['perm_admin'] && $currentUser['role']['perm_org_admin']; $this->CRUD->index([ 'filters' => $this->filterFields, 'quickFilters' => $this->quickFilterFields, @@ -31,6 +33,11 @@ class IndividualsController extends AppController if (!empty($responsePayload)) { return $responsePayload; } + $editableIds = null; + if ($orgAdmin) { + $editableIds = $this->Individuals->getValidIndividualsToEdit($currentUser); + } + $this->set('editableIds', $editableIds); $this->set('alignmentScope', 'individuals'); } @@ -59,6 +66,14 @@ class IndividualsController extends AppController public function edit($id) { + $currentUser = $this->ACL->getUser(); + $validIndividualIds = []; + if ($currentUser['role']['perm_admin']) { + $validIndividualIds = $this->Individuals->getValidIndividualsToEdit($currentUser); + if (!isset($validIndividualIds[$id])) { + throw new NotFoundException(__('Invalid individual.')); + } + } $this->CRUD->edit($id); $responsePayload = $this->CRUD->getResponsePayload(); if (!empty($responsePayload)) { diff --git a/src/Model/Table/IndividualsTable.php b/src/Model/Table/IndividualsTable.php index 6ee0fd7..ca5da26 100644 --- a/src/Model/Table/IndividualsTable.php +++ b/src/Model/Table/IndividualsTable.php @@ -110,4 +110,15 @@ class IndividualsTable extends AppTable } return $query->group(['Individuals.id', 'Individuals.uuid']); } + + public function getValidIndividualsToEdit(object $currentUser): array + { + $validIndividualIds = $this->Users->find('list')->select(['individual_id'])->where( + [ + 'organisation_id' => $currentUser['organisation_id'], + 'disabled' => 0 + ] + )->all()->toArray(); + return array_keys($validIndividualIds); + } } diff --git a/templates/Individuals/add.php b/templates/Individuals/add.php index 436d6fa..393419d 100644 --- a/templates/Individuals/add.php +++ b/templates/Individuals/add.php @@ -24,7 +24,7 @@ array( 'field' => 'tag_list', 'type' => 'tags', - 'requirements' => $this->request->getParam('action') === 'edit' + 'requirements' => ($this->request->getParam('action') === 'edit' && $loggedUser['role']['perm_admin']) ), ), 'submit' => array( diff --git a/templates/Individuals/index.php b/templates/Individuals/index.php index 5867f19..2e39178 100644 --- a/templates/Individuals/index.php +++ b/templates/Individuals/index.php @@ -81,12 +81,25 @@ echo $this->element('genericElements/IndexTable/index_table', [ [ 'open_modal' => '/individuals/edit/[onclick_params_data_path]', 'modal_params_data_path' => 'id', - 'icon' => 'edit' + 'icon' => 'edit', + 'complex_requirement' => [ + 'function' => function ($row, $options) use ($loggedUser, $editableIds) { + if ($loggedUser['role']['perm_admin'] || ($editableIds && in_array($row['id'], $editableIds))) { + return true; + } + return false; + } + ] ], [ 'open_modal' => '/individuals/delete/[onclick_params_data_path]', 'modal_params_data_path' => 'id', - 'icon' => 'trash' + 'icon' => 'trash', + 'complex_requirement' => [ + 'function' => function ($row, $options) use ($loggedUser) { + return (bool)$loggedUser['role']['perm_admin']; + } + ] ], ] ] From 73ad04906a49d6eb98b14e96bba0860c548691e5 Mon Sep 17 00:00:00 2001 From: iglocska Date: Wed, 2 Nov 2022 12:11:56 +0100 Subject: [PATCH 15/43] fix: [typo] capitalisation mistake blocking org edits --- src/Controller/OrganisationsController.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Controller/OrganisationsController.php b/src/Controller/OrganisationsController.php index 24fdd27..3106593 100644 --- a/src/Controller/OrganisationsController.php +++ b/src/Controller/OrganisationsController.php @@ -101,8 +101,8 @@ class OrganisationsController extends AppController { $currentUser = $this->ACL->getUser(); if ( - !($currentUser['Organisation']['id'] == $id && $currentUser['Role']['perm_org_admin']) && - !$currentUser['Role']['perm_admin'] + !($currentUser['organisation']['id'] == $id && $currentUser['role']['perm_org_admin']) && + !$currentUser['role']['perm_admin'] ) { throw new MethodNotAllowedException(__('You cannot modify that organisation.')); } From 7504bfab10a464db3e3eb5848e9087e69aef0e68 Mon Sep 17 00:00:00 2001 From: iglocska Date: Fri, 4 Nov 2022 09:31:46 +0100 Subject: [PATCH 16/43] fix: [API] rearrange component - handle collections correctly - return a new collection with the individual values transferred to it after changes - avoids some weird quirks with unsetting related Objects not taking effect --- src/Controller/Component/APIRearrangeComponent.php | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/Controller/Component/APIRearrangeComponent.php b/src/Controller/Component/APIRearrangeComponent.php index e7523c0..561b271 100644 --- a/src/Controller/Component/APIRearrangeComponent.php +++ b/src/Controller/Component/APIRearrangeComponent.php @@ -12,15 +12,19 @@ use Cake\Core\Configure; use Cake\Core\Configure\Engine\PhpConfig; use Cake\Utility\Inflector; use Cake\Routing\Router; +use Cake\Collection\Collection; class APIRearrangeComponent extends Component { - public function rearrangeForAPI(object $data): object + public function rearrangeForAPI(object $data) { if (is_subclass_of($data, 'Iterator')) { - $data->each(function ($value, $key) { + $newData = []; + $data->each(function ($value, $key) use (&$newData) { $value->rearrangeForAPI(); + $newData[] = $value; }); + return new Collection($newData); } else { $data->rearrangeForAPI(); } From 540fd6342347ef55b736884712c7bb28dece5e27 Mon Sep 17 00:00:00 2001 From: iglocska Date: Fri, 4 Nov 2022 09:33:39 +0100 Subject: [PATCH 17/43] fix: [API] cleaned up the individual API --- src/Controller/Component/CRUDComponent.php | 11 +++++++++ src/Model/Entity/AppModel.php | 25 ++++++++++++++++++--- src/Model/Entity/Individual.php | 26 +++++++++++++++++----- 3 files changed, 54 insertions(+), 8 deletions(-) diff --git a/src/Controller/Component/CRUDComponent.php b/src/Controller/Component/CRUDComponent.php index fdb0d60..ba3ba59 100644 --- a/src/Controller/Component/CRUDComponent.php +++ b/src/Controller/Component/CRUDComponent.php @@ -73,6 +73,9 @@ class CRUDComponent extends Component $query->order($options['order']); } if ($this->Controller->ParamHandler->isRest()) { + if ($this->metaFieldsSupported()) { + $query = $this->includeRequestedMetaFields($query); + } $data = $query->all(); if (isset($options['hidden'])) { $data->each(function($value, $key) use ($options) { @@ -98,6 +101,12 @@ class CRUDComponent extends Component }); } } + if ($this->metaFieldsSupported()) { + $metaTemplates = $this->getMetaTemplates()->toArray(); + $data = $data->map(function($value, $key) use ($metaTemplates) { + return $this->attachMetaTemplatesIfNeeded($value, $metaTemplates); + }); + } $this->Controller->restResponsePayload = $this->RestResponse->viewData($data, 'json'); } else { if ($this->metaFieldsSupported()) { @@ -663,7 +672,9 @@ class CRUDComponent extends Component if (!empty($newestTemplate) && !empty($metaTemplates[$i])) { $metaTemplates[$i]['hasNewerVersion'] = $newestTemplate; } + $metaTemplates[$metaTemplate->id]['meta_template_fields'] = $metaTemplates[$metaTemplate->id]['meta_template_fields']; } + $metaTemplates = $metaTemplates; $data['MetaTemplates'] = $metaTemplates; return $data; } diff --git a/src/Model/Entity/AppModel.php b/src/Model/Entity/AppModel.php index a94e78a..5b639b4 100644 --- a/src/Model/Entity/AppModel.php +++ b/src/Model/Entity/AppModel.php @@ -61,7 +61,11 @@ class AppModel extends Entity public function rearrangeTags(array $tags): array { foreach ($tags as &$tag) { - unset($tag['_joinData']); + $tag = [ + 'id' => $tag['id'], + 'name' => $tag['name'], + 'colour' => $tag['colour'] + ]; } return $tags; } @@ -70,11 +74,26 @@ class AppModel extends Entity { $rearrangedAlignments = []; $validAlignmentTypes = ['individual', 'organisation']; + $alignmentDataToKeep = [ + 'individual' => [ + 'id', + 'email' + ], + 'organisation' => [ + 'id', + 'uuid', + 'name' + ] + ]; foreach ($alignments as $alignment) { - foreach ($validAlignmentTypes as $type) { + foreach (array_keys($alignmentDataToKeep) as $type) { if (isset($alignment[$type])) { $alignment[$type]['type'] = $alignment['type']; - $rearrangedAlignments[$type][] = $alignment[$type]; + $temp = []; + foreach ($alignmentDataToKeep[$type] as $field) { + $temp[$field] = $alignment[$type][$field]; + } + $rearrangedAlignments[$type][] = $temp; } } } diff --git a/src/Model/Entity/Individual.php b/src/Model/Entity/Individual.php index cd1cc9d..1afd30c 100644 --- a/src/Model/Entity/Individual.php +++ b/src/Model/Entity/Individual.php @@ -33,12 +33,28 @@ class Individual extends AppModel { $emails = []; if (!empty($this->meta_fields)) { - foreach ($this->meta_fields as $metaField) { - if (str_contains($metaField->field, 'email')) { - $emails[] = $metaField; - } - } + foreach ($this->meta_fields as $metaField) { + if (!empty($metaField->field) && str_contains($metaField->field, 'email')) { + $emails[] = $metaField; + } + } } return $emails; } + + public function rearrangeForAPI(): void + { + if (!empty($this->tags)) { + $this->tags = $this->rearrangeTags($this->tags); + } + if (!empty($this->alignments)) { + $this->alignments = $this->rearrangeAlignments($this->alignments); + } + if (!empty($this->meta_fields)) { + $this->rearrangeMetaFields(); + } + if (!empty($this->MetaTemplates)) { + unset($this->MetaTemplates); + } + } } From 003053f13db5b78c9ef11249c5136d8d0d87de80 Mon Sep 17 00:00:00 2001 From: iglocska Date: Fri, 4 Nov 2022 09:58:55 +0100 Subject: [PATCH 18/43] new: [API rearrange] added a simplifier for commonly used objects - let's only return the subset of the data required to identify and retrieve the related data --- src/Model/Entity/AppModel.php | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/Model/Entity/AppModel.php b/src/Model/Entity/AppModel.php index 5b639b4..8dcd07f 100644 --- a/src/Model/Entity/AppModel.php +++ b/src/Model/Entity/AppModel.php @@ -99,4 +99,22 @@ class AppModel extends Entity } return $rearrangedAlignments; } + + public function rearrangeSimplify(array $typesToRearrange): void + { + if (in_array('organisation', $typesToRearrange) && isset($this->organisation)) { + $this->organisation = [ + 'id' => $this->organisation['id'], + 'name' => $this->organisation['name'], + 'uuid' => $this->organisation['uuid'] + ]; + } + if (in_array('individual', $typesToRearrange) && isset($this->individual)) { + $this->individual = [ + 'id' => $this->individual['id'], + 'email' => $this->individual['email'], + 'uuid' => $this->individual['uuid'] + ]; + } + } } From 7675e27e90286d0223aa469d0bdfde1ef70bb02d Mon Sep 17 00:00:00 2001 From: iglocska Date: Fri, 4 Nov 2022 09:59:48 +0100 Subject: [PATCH 19/43] chg: [user index] API refactored --- src/Model/Entity/User.php | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/src/Model/Entity/User.php b/src/Model/Entity/User.php index cf8fb6e..5f39720 100644 --- a/src/Model/Entity/User.php +++ b/src/Model/Entity/User.php @@ -11,7 +11,7 @@ use App\Settings\SettingsProvider\UserSettingsProvider; class User extends AppModel { - protected $_hidden = ['password', 'confirm_password']; + protected $_hidden = ['password', 'confirm_password', 'user_settings_by_name', 'user_settings_by_name_with_fallback', 'SettingsProvider', 'user_settings']; protected $_virtual = ['user_settings_by_name', 'user_settings_by_name_with_fallback']; @@ -51,11 +51,36 @@ class User extends AppModel public function rearrangeForAPI(): void { + if (!empty($this->tags)) { + $this->tags = $this->rearrangeTags($this->tags); + } if (!empty($this->meta_fields)) { $this->rearrangeMetaFields(); } if (!empty($this->MetaTemplates)) { unset($this->MetaTemplates); } + if (!empty($this->user_settings_by_name)) { + $this->rearrangeUserSettings(); + } + $this->rearrangeSimplify(['organisation', 'individual']); + } + + private function rearrangeUserSettings() + { + $settings = []; + if (isset($this->user_settings_by_name)) { + foreach ($this->user_settings_by_name as $setting => $data) { + $settings[$setting] = $data['value']; + } + } + if (isset($this->user_settings_by_name_with_fallback)) { + foreach ($this->user_settings_by_name_with_fallback as $setting => $data) { + if (!isset($settings[$setting])) { + $settings[$setting] = $data['value']; + } + } + } + $this->settings = $settings; } } From 10ce8beb30977abbd59635ded0fbb8e0070b0a5e Mon Sep 17 00:00:00 2001 From: iglocska Date: Wed, 9 Nov 2022 13:58:02 +0100 Subject: [PATCH 20/43] chg: [CRUD] component - only try to unlink MetaFields if it's actually loaded --- src/Controller/Component/CRUDComponent.php | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/Controller/Component/CRUDComponent.php b/src/Controller/Component/CRUDComponent.php index ba3ba59..5216086 100644 --- a/src/Controller/Component/CRUDComponent.php +++ b/src/Controller/Component/CRUDComponent.php @@ -554,7 +554,14 @@ class CRUDComponent extends Component $savedData = $this->Table->save($data); if ($savedData !== false) { if ($this->metaFieldsSupported() && !empty($metaFieldsToDelete)) { - $this->Table->MetaFields->unlink($savedData, $metaFieldsToDelete); + foreach ($metaFieldsToDelete as $k => $v) { + if ($v === null) { + unset($metaFieldsToDelete[$k]); + } + } + if (!empty($metaFieldsToDelete)) { + $this->Table->MetaFields->unlink($savedData, $metaFieldsToDelete); + } } if (isset($params['afterSave'])) { $params['afterSave']($data); From 4ec052a2acf1b3206d6c92f23fcd5cc3b8e5a485 Mon Sep 17 00:00:00 2001 From: iglocska Date: Wed, 9 Nov 2022 13:58:39 +0100 Subject: [PATCH 21/43] chg: [soacialauth] listener updated to not save the user - it wasn't making any changes anyway, but triggering a slow process --- src/Event/SocialAuthListener.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Event/SocialAuthListener.php b/src/Event/SocialAuthListener.php index 0daf11d..c661f4c 100644 --- a/src/Event/SocialAuthListener.php +++ b/src/Event/SocialAuthListener.php @@ -31,7 +31,7 @@ class SocialAuthListener implements EventListenerInterface // You can access the profile using $user->social_profile - $this->getTableLocator()->get('Users')->saveOrFail($user); + // $this->getTableLocator()->get('Users')->saveOrFail($user); return $user; } From 16a3826cefa29d4b5e74ab5529ecc449d8ee9b2c Mon Sep 17 00:00:00 2001 From: iglocska Date: Wed, 9 Nov 2022 13:59:39 +0100 Subject: [PATCH 22/43] chg: [encryption keys] rearranged for the API --- src/Model/Entity/EncryptionKey.php | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/Model/Entity/EncryptionKey.php b/src/Model/Entity/EncryptionKey.php index c2f0f04..a9b2f67 100644 --- a/src/Model/Entity/EncryptionKey.php +++ b/src/Model/Entity/EncryptionKey.php @@ -7,4 +7,9 @@ use Cake\ORM\Entity; class EncryptionKey extends AppModel { + + public function rearrangeForAPI(): void + { + $this->rearrangeSimplify(['organisation', 'individual']); + } } From d43e7c6c222ada4819a18807a8f8740336072b73 Mon Sep 17 00:00:00 2001 From: iglocska Date: Wed, 9 Nov 2022 14:00:18 +0100 Subject: [PATCH 23/43] new: [generic form] added checkbox option for boolean fields in metatemplates --- templates/element/genericElements/Form/genericForm.php | 3 +++ templates/element/genericElements/Form/metaTemplateForm.php | 6 ++++++ 2 files changed, 9 insertions(+) diff --git a/templates/element/genericElements/Form/genericForm.php b/templates/element/genericElements/Form/genericForm.php index 1a2bc58..7dafa18 100644 --- a/templates/element/genericElements/Form/genericForm.php +++ b/templates/element/genericElements/Form/genericForm.php @@ -112,6 +112,7 @@ echo $this->Bootstrap->modal([ 'title' => empty($data['title']) ? sprintf('%s %s', $actionName, $modelName) : h($data['title']), 'bodyHtml' => $this->element('genericElements/Form/formLayouts/formRaw', [ + 'data' => $data, 'formCreate' => $formCreate, 'ajaxFlashMessage' => $ajaxFlashMessage, 'fieldsString' => $fieldsString, @@ -124,6 +125,7 @@ ]); } else if (!empty($raw)) { echo $this->element('genericElements/Form/formLayouts/formDefault', [ + 'data' => $data, 'actionName' => $actionName, 'modelName' => $modelName, 'submitButtonData' => $submitButtonData, @@ -135,6 +137,7 @@ ]); } else { echo $this->element('genericElements/Form/formLayouts/formDefault', [ + 'data' => $data, 'actionName' => $actionName, 'modelName' => $modelName, 'submitButtonData' => $submitButtonData, diff --git a/templates/element/genericElements/Form/metaTemplateForm.php b/templates/element/genericElements/Form/metaTemplateForm.php index 0a4d934..c644a72 100644 --- a/templates/element/genericElements/Form/metaTemplateForm.php +++ b/templates/element/genericElements/Form/metaTemplateForm.php @@ -37,6 +37,9 @@ foreach ($metaTemplate->meta_template_fields as $metaTemplateField) { } else { $fieldData['field'] = sprintf('MetaTemplates.%s.meta_template_fields.%s.metaFields.%s.value', $metaField->meta_template_id, $metaField->meta_template_field_id, array_key_first($metaTemplateField->metaFields)); } + if ($metaTemplateField->type === 'boolean') { + $fieldData['type'] = 'checkbox'; + } $this->Form->setTemplates($backupTemplates); $fieldsHtml .= $this->element( 'genericElements/Form/fieldScaffold', @@ -64,6 +67,9 @@ foreach ($metaTemplate->meta_template_fields as $metaTemplateField) { 'field' => sprintf('MetaTemplates.%s.meta_template_fields.%s.metaFields.new.0', $metaTemplateField->meta_template_id, $metaTemplateField->id), 'label' => $metaTemplateField->label, ]; + if ($metaTemplateField->type === 'boolean') { + $fieldData['type'] = 'checkbox'; + } $fieldsHtml .= $this->element( 'genericElements/Form/fieldScaffold', [ From c44e1a84180aa2afb50c3fade9dcf6d544e99640 Mon Sep 17 00:00:00 2001 From: iglocska Date: Wed, 9 Nov 2022 14:01:37 +0100 Subject: [PATCH 24/43] new: [metafields] added view representation of info/warning/danger keys --- .../genericElements/SingleViews/Fields/genericField.php | 5 +++++ .../element/genericElements/SingleViews/metafields_panel.php | 5 ++++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/templates/element/genericElements/SingleViews/Fields/genericField.php b/templates/element/genericElements/SingleViews/Fields/genericField.php index 0223543..cc62363 100644 --- a/templates/element/genericElements/SingleViews/Fields/genericField.php +++ b/templates/element/genericElements/SingleViews/Fields/genericField.php @@ -27,4 +27,9 @@ if (!empty($field['url'])) { } else if (empty($field['raw'])) { $string = h($string); } +foreach (['info', 'warning', 'danger'] as $message_type) { + if (!empty($field[$message_type])) { + $string .= sprintf(' (%s)', $message_type, $field[$message_type]); + } +} echo $string; diff --git a/templates/element/genericElements/SingleViews/metafields_panel.php b/templates/element/genericElements/SingleViews/metafields_panel.php index e426679..d7f2e19 100644 --- a/templates/element/genericElements/SingleViews/metafields_panel.php +++ b/templates/element/genericElements/SingleViews/metafields_panel.php @@ -17,7 +17,10 @@ foreach($data['MetaTemplates'] as $metaTemplate) { foreach ($metaTemplateField->metaFields as $metaField) { $fields[] = [ 'key' => !$labelPrintedOnce ? $metaField->field : '', - 'raw' => $metaField->value + 'raw' => $metaField->value, + 'warning' => $metaField->warning ?? null, + 'info' => $metaField->info ?? null, + 'danger' => $metaField->danger ?? null ]; $labelPrintedOnce = true; } From 9d2c152a4e523f013c49d8c68cdb0b5cd47a0054 Mon Sep 17 00:00:00 2001 From: iglocska Date: Wed, 9 Nov 2022 14:03:16 +0100 Subject: [PATCH 25/43] chg: [bootstrap helper] added warning/info/danger fields --- src/View/Helper/BootstrapHelper.php | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/View/Helper/BootstrapHelper.php b/src/View/Helper/BootstrapHelper.php index 12ccad9..f8d5024 100644 --- a/src/View/Helper/BootstrapHelper.php +++ b/src/View/Helper/BootstrapHelper.php @@ -811,7 +811,10 @@ class BoostrapListTable extends BootstrapGeneric private function genCell($field = []) { if (isset($field['raw'])) { - $cellContent = h($field['raw']); + $cellContent = $field['raw']; + if (empty($field['no_escaping'])) { + $field['raw'] = h($field['raw']); + } } else if (isset($field['formatter'])) { $cellContent = $field['formatter']($this->getValueFromObject($field), $this->item); } else if (isset($field['type'])) { @@ -822,6 +825,11 @@ class BoostrapListTable extends BootstrapGeneric } else { $cellContent = h($this->getValueFromObject($field)); } + foreach (['info', 'warning', 'danger'] as $message_type) { + if (!empty($field[$message_type])) { + $cellContent .= sprintf(' %s', $message_type, $field[$message_type]); + } + } return $this->genNode('td', [ 'class' => [ 'col-8 col-sm-10', From b1f09dc97ee4dc270980cfa970b445b1eba7b833 Mon Sep 17 00:00:00 2001 From: iglocska Date: Wed, 9 Nov 2022 14:09:27 +0100 Subject: [PATCH 26/43] new: [permission limitations] subsystem added - add limitations for users with given meta fields - x number / org and y number / globally - add comments to the limitations - enforced on user creation/modification --- src/Controller/Component/ACLComponent.php | 7 ++ .../Component/Navigation/sidemenu.php | 5 + .../PermissionLimitationsController.php | 91 ++++++++++++++ src/Controller/UsersController.php | 11 +- src/Model/Entity/PermissionLimitation.php | 11 ++ .../Table/PermissionLimitationsTable.php | 119 ++++++++++++++++++ src/Model/Table/UsersTable.php | 47 ++++++- templates/PermissionLimitations/add.php | 38 ++++++ templates/PermissionLimitations/index.php | 79 ++++++++++++ templates/PermissionLimitations/view.php | 30 +++++ 10 files changed, 431 insertions(+), 7 deletions(-) create mode 100644 src/Controller/PermissionLimitationsController.php create mode 100644 src/Model/Entity/PermissionLimitation.php create mode 100644 src/Model/Table/PermissionLimitationsTable.php create mode 100644 templates/PermissionLimitations/add.php create mode 100644 templates/PermissionLimitations/index.php create mode 100644 templates/PermissionLimitations/view.php diff --git a/src/Controller/Component/ACLComponent.php b/src/Controller/Component/ACLComponent.php index 095c548..9c3dd4c 100644 --- a/src/Controller/Component/ACLComponent.php +++ b/src/Controller/Component/ACLComponent.php @@ -169,6 +169,13 @@ class ACLComponent extends Component 'Pages' => [ 'display' => ['*'] ], + 'PermissionLimitations' => [ + "index" => ['*'], + "add" => ['perm_admin'], + "view" => ['*'], + "edit" => ['perm_admin'], + "delete" => ['perm_admin'] + ], 'Roles' => [ 'add' => ['perm_admin'], 'delete' => ['perm_admin'], diff --git a/src/Controller/Component/Navigation/sidemenu.php b/src/Controller/Component/Navigation/sidemenu.php index e9b9281..84fcf6c 100644 --- a/src/Controller/Component/Navigation/sidemenu.php +++ b/src/Controller/Component/Navigation/sidemenu.php @@ -123,6 +123,11 @@ class Sidemenu { 'url' => '/auditLogs/index', 'icon' => 'history', ], + 'PermissionLimitations' => [ + 'label' => __('Permission Limitations'), + 'url' => '/permissionLimitations/index', + 'icon' => 'jedi', + ], ] ], 'API' => [ diff --git a/src/Controller/PermissionLimitationsController.php b/src/Controller/PermissionLimitationsController.php new file mode 100644 index 0000000..b3d98f6 --- /dev/null +++ b/src/Controller/PermissionLimitationsController.php @@ -0,0 +1,91 @@ +CRUD->index([ + 'filters' => $this->filterFields, + 'quickFilters' => $this->quickFilterFields, + 'afterFind' => function($data) { + $data['comment'] = is_resource($data['comment']) ? stream_get_contents($data['comment']) : $data['comment']; + return $data; + } + ]); + $responsePayload = $this->CRUD->getResponsePayload(); + if (!empty($responsePayload)) { + return $responsePayload; + } + $this->set('metaGroup', 'PermissionLimitations'); + } + + public function add() + { + $this->CRUD->add([ + 'afterFind' => function($data) { + $data['comment'] = is_resource($data['comment']) ? stream_get_contents($data['comment']) : $data['comment']; + return $data; + } + ]); + $responsePayload = $this->CRUD->getResponsePayload(); + if (!empty($responsePayload)) { + return $responsePayload; + } + $this->set('metaGroup', 'PermissionLimitations'); + } + + public function view($id) + { + $this->CRUD->view($id, [ + 'afterFind' => function($data) { + $data['comment'] = is_resource($data['comment']) ? stream_get_contents($data['comment']) : $data['comment']; + return $data; + } + ]); + $responsePayload = $this->CRUD->getResponsePayload(); + if (!empty($responsePayload)) { + return $responsePayload; + } + $this->set('metaGroup', 'PermissionLimitations'); + } + + public function edit($id) + { + $this->CRUD->edit($id, [ + 'afterFind' => function($data) { + $data['comment'] = is_resource($data['comment']) ? stream_get_contents($data['comment']) : $data['comment']; + return $data; + } + ]); + $responsePayload = $this->CRUD->getResponsePayload(); + if (!empty($responsePayload)) { + return $responsePayload; + } + $this->set('metaGroup', 'PermissionLimitations'); + $this->render('add'); + } + + public function delete($id) + { + $this->CRUD->delete($id); + $responsePayload = $this->CRUD->getResponsePayload(); + if (!empty($responsePayload)) { + return $responsePayload; + } + $this->set('metaGroup', 'PermissionLimitations'); + } +} diff --git a/src/Controller/UsersController.php b/src/Controller/UsersController.php index 426f4a3..091382a 100644 --- a/src/Controller/UsersController.php +++ b/src/Controller/UsersController.php @@ -137,7 +137,11 @@ class UsersController extends AppController $id = $this->ACL->getUser()['id']; } $this->CRUD->view($id, [ - 'contain' => ['Individuals' => ['Alignments' => 'Organisations'], 'Roles', 'Organisations'] + 'contain' => ['Individuals' => ['Alignments' => 'Organisations'], 'Roles', 'Organisations'], + 'afterFind' => function($data) { + $data = $this->fetchTable('PermissionLimitations')->attachLimitations($data); + return $data; + } ]); $responsePayload = $this->CRUD->getResponsePayload(); if (!empty($responsePayload)) { @@ -414,9 +418,4 @@ class UsersController extends AppController } $this->viewBuilder()->setLayout('login'); } - - public function test() - { - - } } diff --git a/src/Model/Entity/PermissionLimitation.php b/src/Model/Entity/PermissionLimitation.php new file mode 100644 index 0000000..17b0e4c --- /dev/null +++ b/src/Model/Entity/PermissionLimitation.php @@ -0,0 +1,11 @@ +addBehavior('AuditLog'); + $this->setDisplayField('permission'); + } + + public function validationDefault(Validator $validator): Validator + { + $validator + ->notEmptyString('permission') + ->notEmptyString('scope') + ->requirePresence(['permission', 'scope', 'max_occurrence'], 'create'); + return $validator; + } + + public function getListOfLimitations(\App\Model\Entity\User $data) + { + $Users = TableRegistry::getTableLocator()->get('Users'); + $ownOrgUserIds = $Users->find('list', [ + 'keyField' => 'id', + 'valueField' => 'id', + 'conditions' => [ + 'organisation_id' => $data['organisation_id'] + ] + ])->all()->toList(); + $MetaFields = TableRegistry::getTableLocator()->get('MetaFields'); + $raw = $this->find()->select(['scope', 'permission', 'max_occurrence'])->disableHydration()->toArray(); + $limitations = []; + foreach ($raw as $entry) { + $limitations[$entry['permission']][$entry['scope']] = [ + 'limit' => $entry['max_occurrence'] + ]; + } + foreach ($limitations as $field => $data) { + if (isset($data['global'])) { + $limitations[$field]['global']['current'] = $MetaFields->find('all', [ + 'conditions' => [ + 'scope' => 'user', + 'field' => $field + ] + ])->count(); + } + if (isset($data['global'])) { + $limitations[$field]['organisation']['current'] = $MetaFields->find('all', [ + 'conditions' => [ + 'scope' => 'user', + 'field' => $field, + 'parent_id IN' => array_values($ownOrgUserIds) + ] + ])->count(); + } + } + return $limitations; + } + + public function attachLimitations(\App\Model\Entity\User $data) + { + $permissionLimitations = $this->getListOfLimitations($data); + $icons = [ + 'global' => 'globe', + 'organisation' => 'sitemap' + + ]; + if (!empty($data['MetaTemplates'])) { + foreach ($data['MetaTemplates'] as &$metaTemplate) { + foreach ($metaTemplate['meta_template_fields'] as &$meta_template_field) { + $boolean = $meta_template_field['type'] === 'boolean'; + foreach ($meta_template_field['metaFields'] as &$metaField) { + if ($boolean) { + $metaField['value'] = ''; + $metaField['no_escaping'] = true; + } + if (isset($permissionLimitations[$metaField['field']])) { + foreach ($permissionLimitations[$metaField['field']] as $scope => $value) { + $messageType = 'warning'; + if ($value['limit'] > $value['current']) { + $messageType = 'info'; + } + if ($value['limit'] < $value['current']) { + $messageType = 'danger'; + } + if (empty($metaField[$messageType])) { + $metaField[$messageType] = ''; + } + $altText = __( + 'There is a limitation enforced on the number of users with this permission {0}. Currently {1} slot(s) are used up of a maximum of {2} slot(s).', + $scope === 'global' ? __('instance wide') : __('for your organisation'), + $value['current'], + $value['limit'] + ); + $metaField[$messageType] .= sprintf( + ' : %s/%s', + $altText, + $icons[$scope], + $value['current'], + $value['limit'] + ); + } + } + } + } + } + } + return $data; + } +} diff --git a/src/Model/Table/UsersTable.php b/src/Model/Table/UsersTable.php index 2cfe8b2..647d32f 100644 --- a/src/Model/Table/UsersTable.php +++ b/src/Model/Table/UsersTable.php @@ -59,7 +59,9 @@ class UsersTable extends AppTable public function beforeMarshal(EventInterface $event, ArrayObject $data, ArrayObject $options) { - $data['username'] = trim(mb_strtolower($data['username'])); + if (isset($data['username'])) { + $data['username'] = trim(mb_strtolower($data['username'])); + } } public function beforeSave(EventInterface $event, EntityInterface $entity, ArrayObject $options) @@ -67,9 +69,51 @@ class UsersTable extends AppTable if (!$entity->isNew()) { $success = $this->handleUserUpdateRouter($entity); } + $permissionRestrictionCheck = $this->checkPermissionRestrictions($entity); + if ($permissionRestrictionCheck !== true) { + $entity->setErrors($permissionRestrictionCheck); + $event->stopPropagation(); + $event->setResult(false); + return false; + } return $success; } + private function checkPermissionRestrictions(EntityInterface $entity) + { + if (!isset($this->PermissionLimitations)) { + $this->PermissionLimitations = TableRegistry::get('PermissionLimitations'); + } + $new = $entity->isNew(); + $permissions = $this->PermissionLimitations->getListOfLimitations($entity); + foreach ($permissions as $permission_name => $permission) { + foreach ($permission as $scope => $permission_data) { + if (!empty($entity['meta_fields'])) { + $enabled = false; + foreach ($entity['meta_fields'] as $metaField) { + if ($metaField['field'] === $permission_name) { + $enabled = true; + } + } + if (!$enabled) { + continue; + } + } + $valueToCompareTo = $permission_data['current'] + ($new ? 1 : 0); + if ($valueToCompareTo > $permission_data['limit']) { + return [ + $permission_name => + __( + '{0} limit exceeded.', + $scope + ) + ]; + } + } + } + return true; + } + private function initAuthBehaviors() { if (!empty(Configure::read('keycloak'))) { @@ -80,6 +124,7 @@ class UsersTable extends AppTable public function validationDefault(Validator $validator): Validator { $validator + ->setStopOnFailure() ->requirePresence(['password'], 'create') ->add('password', [ 'password_complexity' => [ diff --git a/templates/PermissionLimitations/add.php b/templates/PermissionLimitations/add.php new file mode 100644 index 0000000..0dffc70 --- /dev/null +++ b/templates/PermissionLimitations/add.php @@ -0,0 +1,38 @@ +element('genericElements/Form/genericForm', [ + 'data' => [ + 'description' => __( + 'Add a limitation of how many users can have the given permission. The scope applies the limitation globally or for a given organisation. + Permissions can be valid role permissions or any user meta field. + An example: perm_misp global limit 500, organisation limit 10 would ensure that there are a maximum of 500 MISP admitted users on the instance, limiting the number of users to 10 / org.' + ), + 'model' => 'PermissionLimitation', + 'fields' => [ + [ + 'field' => 'scope', + 'type' => 'dropdown', + 'label' => 'Scope', + 'options' => [ + 'global' => 'global', + 'organisation' => 'organisation' + ] + ], + [ + 'field' => 'permission' + ], + [ + 'field' => 'max_occurrence', + 'label' => 'Limit' + ], + [ + 'field' => 'comment', + 'label' => 'Comment' + ] + ], + 'submit' => [ + 'action' => $this->request->getParam('action') + ] + ] + ]); +?> + diff --git a/templates/PermissionLimitations/index.php b/templates/PermissionLimitations/index.php new file mode 100644 index 0000000..20dff1c --- /dev/null +++ b/templates/PermissionLimitations/index.php @@ -0,0 +1,79 @@ +element('genericElements/IndexTable/index_table', [ + 'data' => [ + 'data' => $data, + 'top_bar' => [ + 'children' => [ + [ + 'type' => 'simple', + 'children' => [ + 'data' => [ + 'type' => 'simple', + 'text' => __('Add permission limitation'), + 'class' => 'btn btn-primary', + 'popover_url' => '/PermissionLimitations/add' + ] + ] + ], + [ + 'type' => 'search', + 'button' => __('Search'), + 'placeholder' => __('Enter value to search'), + 'data' => '', + 'searchKey' => 'value' + ] + ] + ], + 'fields' => [ + [ + 'name' => '#', + 'sort' => 'id', + 'data_path' => 'id', + ], + [ + 'name' => __('Scope'), + 'sort' => 'scope', + 'data_path' => 'scope', + ], + [ + 'name' => __('Permission'), + 'sort' => 'permission', + 'data_path' => 'permission' + ], + [ + 'name' => __('Limit'), + 'sort' => 'max_occurrence', + 'data_path' => 'max_occurrence' + ], + [ + 'name' => __('Comment'), + 'sort' => 'comment', + 'data_path' => 'comment' + ] + ], + 'title' => __('Permission Limitations Index'), + 'description' => __('A list of configurable user roles. Create or modify user access roles based on the settings below.'), + 'pull' => 'right', + 'actions' => [ + [ + 'url' => '/permissionLimitations/view', + 'url_params_data_paths' => ['id'], + 'icon' => 'eye' + ], + [ + 'open_modal' => '/permissionLimitations/edit/[onclick_params_data_path]', + 'modal_params_data_path' => 'id', + 'icon' => 'edit', + 'requirement' => !empty($loggedUser['role']['perm_admin']) + ], + [ + 'open_modal' => '/permissionLimitations/delete/[onclick_params_data_path]', + 'modal_params_data_path' => 'id', + 'icon' => 'trash', + 'requirement' => !empty($loggedUser['role']['perm_admin']) + ], + ] + ] +]); +echo ''; +?> diff --git a/templates/PermissionLimitations/view.php b/templates/PermissionLimitations/view.php new file mode 100644 index 0000000..0ec3852 --- /dev/null +++ b/templates/PermissionLimitations/view.php @@ -0,0 +1,30 @@ +element( + '/genericElements/SingleViews/single_view', + [ + 'data' => $entity, + 'fields' => [ + [ + 'key' => __('ID'), + 'path' => 'id' + ], + [ + 'key' => __('Scope'), + 'path' => 'scope' + ], + [ + 'key' => __('Permission'), + 'path' => 'permission' + ], + [ + 'key' => __('Limit'), + 'path' => 'limit' + ], + [ + 'key' => __('Comment'), + 'path' => 'comment' + ] + ], + 'children' => [] + ] +); From 0d2b4f107d84e1750842a053430273dbb815801d Mon Sep 17 00:00:00 2001 From: iglocska Date: Wed, 9 Nov 2022 14:10:33 +0100 Subject: [PATCH 27/43] new: [permission limitations] upgrade script added --- ...20221108000000_permission_restrictions.php | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 config/Migrations/20221108000000_permission_restrictions.php diff --git a/config/Migrations/20221108000000_permission_restrictions.php b/config/Migrations/20221108000000_permission_restrictions.php new file mode 100644 index 0000000..2211a52 --- /dev/null +++ b/config/Migrations/20221108000000_permission_restrictions.php @@ -0,0 +1,48 @@ +hasTable('permission_limitations'); + if (!$exists) { + $table = $this->table('permission_limitations', [ + 'signed' => false, + 'collation' => 'utf8mb4_unicode_ci', + ]); + $table + ->addColumn('scope', 'string', [ + 'null' => false, + 'length' => 20, + 'collation' => 'ascii_general_ci' + ]) + ->addColumn('permission', 'string', [ + 'null' => false, + 'length' => 40, + 'collation' => 'utf8mb4_unicode_ci' + ]) + ->addColumn('max_occurrence', 'integer', [ + 'null' => false, + 'signed' => false + ]) + ->addColumn('comment', 'blob', []) + ->addIndex('scope') + ->addIndex('permission'); + $table->create(); + } + } +} From 99febe19db6587196fc03cbfa4bd6b04d5cdc817 Mon Sep 17 00:00:00 2001 From: iglocska Date: Wed, 9 Nov 2022 14:10:54 +0100 Subject: [PATCH 28/43] fix: [keycloak] re-enabled the getUser function, removed the placeholder forced user fetch --- src/Model/Behavior/AuthKeycloakBehavior.php | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/Model/Behavior/AuthKeycloakBehavior.php b/src/Model/Behavior/AuthKeycloakBehavior.php index b3300a0..36abea2 100644 --- a/src/Model/Behavior/AuthKeycloakBehavior.php +++ b/src/Model/Behavior/AuthKeycloakBehavior.php @@ -18,11 +18,9 @@ use Cake\Http\Exception\NotFoundException; class AuthKeycloakBehavior extends Behavior { -/* public function getUser(EntityInterface $profile, Session $session) { $userId = $session->read('Auth.User.id'); - $userId = null; if ($userId) { return $this->_table->get($userId); } @@ -35,7 +33,7 @@ class AuthKeycloakBehavior extends Behavior return $user; } -*/ + private function extractProfileData($profile_payload) { $mapping = Configure::read('keycloak.mapping'); From 2a06b3101e3affbcb59d5d5f52a4f63bb96d8de9 Mon Sep 17 00:00:00 2001 From: iglocska Date: Wed, 9 Nov 2022 14:15:06 +0100 Subject: [PATCH 29/43] chg: [version] bump --- src/VERSION.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/VERSION.json b/src/VERSION.json index 1bdd088..1fe70b7 100644 --- a/src/VERSION.json +++ b/src/VERSION.json @@ -1,4 +1,4 @@ { - "version": "1.6", + "version": "1.7", "application": "Cerebrate" } From f4b33d1852d6afe711894dd08aa52f37b17dc1a6 Mon Sep 17 00:00:00 2001 From: iglocska Date: Fri, 11 Nov 2022 10:07:35 +0100 Subject: [PATCH 30/43] fix: [keycloak sync] not needed on user index - was a test that was left in --- src/Controller/UsersController.php | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Controller/UsersController.php b/src/Controller/UsersController.php index 091382a..8ac1735 100644 --- a/src/Controller/UsersController.php +++ b/src/Controller/UsersController.php @@ -16,7 +16,6 @@ class UsersController extends AppController public function index() { - $this->Users->updateMappers(); $currentUser = $this->ACL->getUser(); $conditions = []; if (empty($currentUser['role']['perm_admin'])) { From c2bff491852d3142868f7729d32c1f4c212c853f Mon Sep 17 00:00:00 2001 From: iglocska Date: Fri, 11 Nov 2022 15:08:23 +0100 Subject: [PATCH 31/43] fix: [beforesave] hook removed on get requests --- src/Controller/Component/CRUDComponent.php | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/Controller/Component/CRUDComponent.php b/src/Controller/Component/CRUDComponent.php index 5216086..7699417 100644 --- a/src/Controller/Component/CRUDComponent.php +++ b/src/Controller/Component/CRUDComponent.php @@ -787,12 +787,6 @@ class CRUDComponent extends Component if (empty($data)) { throw new NotFoundException(__('Invalid {0}.', $this->ObjectAlias)); } - if (isset($params['beforeSave'])) { - $data = $params['beforeSave']($data); - if ($data === false) { - throw new NotFoundException(__('Could not save {0} due to the input failing to meet expectations. Your input is bad and you should feel bad.', $this->ObjectAlias)); - } - } $this->Controller->set('id', $data['id']); $this->Controller->set('data', $data); $this->Controller->set('bulkEnabled', false); From f6f94983e4ebae429393c56bc94a4a94e3dd1e0e Mon Sep 17 00:00:00 2001 From: iglocska Date: Fri, 11 Nov 2022 15:08:56 +0100 Subject: [PATCH 32/43] fix: [users] several fixes - User enrollment in KC moved to the aftersave (we consider cerebrate to be authoritative) - adhere to restriction parameters in deletion --- src/Controller/UsersController.php | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/src/Controller/UsersController.php b/src/Controller/UsersController.php index 8ac1735..8de4c92 100644 --- a/src/Controller/UsersController.php +++ b/src/Controller/UsersController.php @@ -96,8 +96,12 @@ class UsersController extends AppController throw new MethodNotAllowedException(__('Invalid individual selected - when KeyCloak is enabled, only one user account may be assigned to an individual.')); } } - $this->Users->enrollUserRouter($data); return $data; + }, + 'afterSave' => function($data) { + if (Configure::read('keycloak.enabled')) { + $this->Users->enrollUserRouter($data); + } } ]); $responsePayload = $this->CRUD->getResponsePayload(); @@ -282,16 +286,21 @@ class UsersController extends AppController 'beforeSave' => function($data) use ($currentUser, $validRoles) { if (!$currentUser['role']['perm_admin']) { if ($data['organisation_id'] !== $currentUser['organisation_id']) { - throw new MethodNotAllowedException(__('You do not have permission to remove the given user.')); + throw new MethodNotAllowedException(__('You do not have permission to delete the given user.')); } if (!in_array($data['role_id'], array_keys($validRoles))) { - throw new MethodNotAllowedException(__('You do not have permission to remove the given user.')); + throw new MethodNotAllowedException(__('You do not have permission to delete the given user.')); + } + } + if (Configure::read('keycloak.enabled')) { + if (!$this->Users->deleteUser($data)) { + throw new MethodNotAllowedException(__('Could not delete the user from KeyCloak. Please try again later, or consider disabling the user instead.')); } } return $data; } ]; - $this->CRUD->delete($id); + $this->CRUD->delete($id, $params); $responsePayload = $this->CRUD->getResponsePayload(); if (!empty($responsePayload)) { return $responsePayload; From 6d41622129fe678cc724208daf37bb8e0185ab73 Mon Sep 17 00:00:00 2001 From: iglocska Date: Fri, 11 Nov 2022 15:10:04 +0100 Subject: [PATCH 33/43] new: [user deletion] tied into KeyCloak - remove user from KC when possible - proceed for local users --- src/Model/Behavior/AuthKeycloakBehavior.php | 56 +++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/src/Model/Behavior/AuthKeycloakBehavior.php b/src/Model/Behavior/AuthKeycloakBehavior.php index 36abea2..2104bda 100644 --- a/src/Model/Behavior/AuthKeycloakBehavior.php +++ b/src/Model/Behavior/AuthKeycloakBehavior.php @@ -84,6 +84,62 @@ class AuthKeycloakBehavior extends Behavior ); } + public function getUserIdByUsername(string $username) + { + $response = $this->restApiRequest( + '%s/admin/realms/%s/users/?username=' . urlencode($username), + [], + 'GET' + ); + if (!$response->isOk()) { + $responseBody = json_decode($response->getStringBody(), true); + $this->_table->auditLogs()->insert([ + 'request_action' => 'keycloakGetUser', + 'model' => 'User', + 'model_id' => 0, + 'model_title' => __('Failed to fetch user ({0}) from keycloak', $username), + 'changed' => ['error' => empty($responseBody['errorMessage']) ? 'Unknown error.' : $responseBody['errorMessage']] + ]); + } + $responseBody = json_decode($response->getStringBody(), true); + if (empty($responseBody[0]['id'])) { + return false; + } + return $responseBody[0]['id']; + } + + public function deleteUser($data): bool + { + $userId = $this->getUserIdByUsername($data['username']); + if ($userId === false) { + $this->_table->auditLogs()->insert([ + 'request_action' => 'keycloakUserDeletion', + 'model' => 'User', + 'model_id' => 0, + 'model_title' => __('User {0} not found in keycloak, deleting the user locally.', $data['username']), + 'changed' => [] + ]); + return true; + } + $response = $this->restApiRequest( + '%s/admin/realms/%s/users/' . urlencode($userId), + [], + 'delete' + ); + if (!$response->isOk()) { + $responseBody = json_decode($response->getStringBody(), true); + $this->_table->auditLogs()->insert([ + 'request_action' => 'keycloakUserDeletion', + 'model' => 'User', + 'model_id' => 0, + 'model_title' => __('Failed to delete user {0} ({1}) in keycloak', $data['username'], $userId), + 'changed' => ['error' => empty($responseBody['errorMessage']) ? 'Unknown error.' : $responseBody['errorMessage']] + ]); + return false; + } + return true; + } + public function enrollUser($data): bool { $roleConditions = [ From ab5cee58ad82471f0c3ab387f86105cd143f13ee Mon Sep 17 00:00:00 2001 From: iglocska Date: Fri, 11 Nov 2022 15:30:55 +0100 Subject: [PATCH 34/43] fix: [crud] speculative fix for notice error on metatemplates being accessed that aren't loaded --- src/Controller/Component/CRUDComponent.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Controller/Component/CRUDComponent.php b/src/Controller/Component/CRUDComponent.php index 7699417..1808f42 100644 --- a/src/Controller/Component/CRUDComponent.php +++ b/src/Controller/Component/CRUDComponent.php @@ -674,6 +674,7 @@ class CRUDComponent extends Component if (!empty($pruneEmptyDisabled) && !$metaTemplate->enabled) { unset($metaTemplates[$i]); } + continue; } $newestTemplate = $this->MetaTemplates->getNewestVersion($metaTemplate); if (!empty($newestTemplate) && !empty($metaTemplates[$i])) { From b697c527b482ab56a3917fa05e64323eff33c7aa Mon Sep 17 00:00:00 2001 From: iglocska Date: Fri, 11 Nov 2022 15:51:32 +0100 Subject: [PATCH 35/43] fix: [pgp key length] extended to mediumtext - Some people drive BMWs, some have pgp keys that exceed the limits of TEXT --- ...0000_support_for_megalomaniac_pgp_keys.php | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 config/Migrations/20221111000000_support_for_megalomaniac_pgp_keys.php diff --git a/config/Migrations/20221111000000_support_for_megalomaniac_pgp_keys.php b/config/Migrations/20221111000000_support_for_megalomaniac_pgp_keys.php new file mode 100644 index 0000000..83148c7 --- /dev/null +++ b/config/Migrations/20221111000000_support_for_megalomaniac_pgp_keys.php @@ -0,0 +1,25 @@ +table('encryption_keys'); + $encryption_keys->changeColumn('encryption_key', 'text', ['limit' => MysqlAdapter::TEXT_MEDIUM])->save(); + } +} From 951fbeaee5a76ea5a9d9f89894eb3fae49941f73 Mon Sep 17 00:00:00 2001 From: iglocska Date: Fri, 11 Nov 2022 15:57:03 +0100 Subject: [PATCH 36/43] fix: [audit logs] change field changed to mediumblob - AAAAAAAaaaaaaaaaaaaaaaaaAAAAAAAAAAAAAAAAAAaaaaaaaaaaAAAAAAAAAAaaaaaaaaAAAAAAAAAAAAaaargh --- ...pport_for_megalomaniac_pgp_keys_round2.php | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 config/Migrations/20221111000001_support_for_megalomaniac_pgp_keys_round2.php diff --git a/config/Migrations/20221111000001_support_for_megalomaniac_pgp_keys_round2.php b/config/Migrations/20221111000001_support_for_megalomaniac_pgp_keys_round2.php new file mode 100644 index 0000000..d431d4a --- /dev/null +++ b/config/Migrations/20221111000001_support_for_megalomaniac_pgp_keys_round2.php @@ -0,0 +1,25 @@ +table('audit_logs'); + $encryption_keys->changeColumn('changed', 'blob', ['limit' => MysqlAdapter::BLOB_MEDIUM])->save(); + } +} From 260e1d30a121e3070fb859e81f489951f44b597c Mon Sep 17 00:00:00 2001 From: iglocska Date: Sun, 13 Nov 2022 11:09:34 +0100 Subject: [PATCH 37/43] new: [individuals] rework - allow modifications for org admins of individuals associated to their users - unless they're site admins - add user information to the individual index to bring clarity to the individual vs users confusion - rework of the user form field object --- src/Controller/IndividualsController.php | 22 +++++++++++++++- src/Model/Table/IndividualsTable.php | 4 ++- templates/Individuals/index.php | 6 +++++ .../IndexTable/Fields/user.php | 26 ++++++++++++++----- 4 files changed, 50 insertions(+), 8 deletions(-) diff --git a/src/Controller/IndividualsController.php b/src/Controller/IndividualsController.php index d88d297..66271e3 100644 --- a/src/Controller/IndividualsController.php +++ b/src/Controller/IndividualsController.php @@ -28,6 +28,12 @@ class IndividualsController extends AppController 'quickFilterForMetaField' => ['enabled' => true, 'wildcard_search' => true], 'contain' => $this->containFields, 'statisticsFields' => $this->statisticsFields, + 'afterFind' => function($data) use ($currentUser) { + if ($currentUser['role']['perm_admin']) { + $data['user'] = $this->Individuals->Users->find()->select(['id', 'username', 'Organisations.id', 'Organisations.name'])->contain('Organisations')->where(['individual_id' => $data['id']])->all()->toArray(); + } + return $data; + } ]); $responsePayload = $this->CRUD->getResponsePayload(); if (!empty($responsePayload)) { @@ -66,6 +72,13 @@ class IndividualsController extends AppController public function edit($id) { + $currentUser = $this->ACL->getUser(); + if (!$currentUser['role']['perm_admin']) { + $validIndividuals = $this->Individuals->getValidIndividualsToEdit($currentUser); + if (!in_array($id, $validIndividuals)) { + throw new MethodNotAllowedException(__('You cannot modify that individual.')); + } + } $currentUser = $this->ACL->getUser(); $validIndividualIds = []; if ($currentUser['role']['perm_admin']) { @@ -74,7 +87,14 @@ class IndividualsController extends AppController throw new NotFoundException(__('Invalid individual.')); } } - $this->CRUD->edit($id); + $this->CRUD->edit($id, [ + 'beforeSave' => function($data) use ($currentUser) { + if ($currentUser['role']['perm_admin'] && isset($data['uuid'])) { + unset($data['uuid']); + } + return $data; + } + ]); $responsePayload = $this->CRUD->getResponsePayload(); if (!empty($responsePayload)) { return $responsePayload; diff --git a/src/Model/Table/IndividualsTable.php b/src/Model/Table/IndividualsTable.php index ca5da26..c0b81ab 100644 --- a/src/Model/Table/IndividualsTable.php +++ b/src/Model/Table/IndividualsTable.php @@ -113,10 +113,12 @@ class IndividualsTable extends AppTable public function getValidIndividualsToEdit(object $currentUser): array { + $adminRoles = $this->Users->Roles->find('list')->select(['id'])->where(['perm_admin' => 1])->all()->toArray(); $validIndividualIds = $this->Users->find('list')->select(['individual_id'])->where( [ 'organisation_id' => $currentUser['organisation_id'], - 'disabled' => 0 + 'disabled' => 0, + 'role_id NOT IN' => array_keys($adminRoles) ] )->all()->toArray(); return array_keys($validIndividualIds); diff --git a/templates/Individuals/index.php b/templates/Individuals/index.php index 2e39178..4a662aa 100644 --- a/templates/Individuals/index.php +++ b/templates/Individuals/index.php @@ -52,6 +52,12 @@ echo $this->element('genericElements/IndexTable/index_table', [ 'sort' => 'last_name', 'data_path' => 'last_name', ], + [ + 'name' => __('Associated User(s)'), + 'sort' => 'user', + 'data_path' => 'user', + 'element' => 'user' + ], [ 'name' => __('Alignments'), 'data_path' => 'alignments', diff --git a/templates/element/genericElements/IndexTable/Fields/user.php b/templates/element/genericElements/IndexTable/Fields/user.php index 25504fc..8f6b82f 100644 --- a/templates/element/genericElements/IndexTable/Fields/user.php +++ b/templates/element/genericElements/IndexTable/Fields/user.php @@ -1,11 +1,25 @@ Hash->extract($row, 'user.id')[0]; - $userName = $this->Hash->extract($row, 'user.username')[0]; - echo $this->Html->link( - h($userName), - ['controller' => 'users', 'action' => 'view', $userId] - ); + if (isset($row['user']['id'])) { + $users = [$row['user']]; + } else { + $users = $row['user']; + } + $links = []; + foreach ($users as $user) { + $orgPrepend = ''; + if (!empty($user['organisation']['name']) && !empty($user['organisation']['id'])) { + $orgPrepend = '[' . $this->Html->link( + h($user['organisation']['name']), + ['controller' => 'organisations', 'action' => 'view', $user['organisation']['id']] + ) . '] '; + } + $links[] = $orgPrepend . $this->Html->link( + h($user['username']), + ['controller' => 'users', 'action' => 'view', $user['id']] + ); + } + echo implode('
', $links); } ?> From 5c02f1c6a422edd4e0a676513697fc2f0e27c953 Mon Sep 17 00:00:00 2001 From: iglocska Date: Sun, 13 Nov 2022 11:18:54 +0100 Subject: [PATCH 38/43] chg: [cleanup] of the default role setting for keycloak - not used in any sensible way anymore --- src/Model/Behavior/AuthKeycloakBehavior.php | 3 --- .../CerebrateSettingsProvider.php | 21 ------------------- src/Model/Table/UsersTable.php | 4 ---- 3 files changed, 28 deletions(-) diff --git a/src/Model/Behavior/AuthKeycloakBehavior.php b/src/Model/Behavior/AuthKeycloakBehavior.php index 2104bda..0a485dd 100644 --- a/src/Model/Behavior/AuthKeycloakBehavior.php +++ b/src/Model/Behavior/AuthKeycloakBehavior.php @@ -145,9 +145,6 @@ class AuthKeycloakBehavior extends Behavior $roleConditions = [ 'id' => $data['role_id'] ]; - if (!empty(Configure::read('keycloak.user_management.actions'))) { - $roleConditions['name'] = Configure::read('keycloak.default_role_name'); - } $user = [ 'username' => $data['username'], 'disabled' => false, diff --git a/src/Model/Table/SettingProviders/CerebrateSettingsProvider.php b/src/Model/Table/SettingProviders/CerebrateSettingsProvider.php index 421f6b5..dfa2be4 100644 --- a/src/Model/Table/SettingProviders/CerebrateSettingsProvider.php +++ b/src/Model/Table/SettingProviders/CerebrateSettingsProvider.php @@ -208,27 +208,6 @@ class CerebrateSettingsProvider extends BaseSettingsProvider return true; } ], - 'keycloak.authoritative' => [ - 'name' => 'Authoritative', - 'type' => 'boolean', - 'severity' => 'info', - 'description' => __('Override local role and organisation settings based on the settings in KeyCloak'), - 'default' => false, - 'dependsOn' => 'keycloak.enabled' - ], - 'keycloak.default_role_name' => [ - 'name' => 'Default role', - 'type' => 'select', - 'severity' => 'info', - 'description' => __('Select the default role name to be used when creating users'), - 'options' => function ($settingsProviders) { - $roleTable = TableRegistry::getTableLocator()->get('Roles'); - $allRoleNames = $roleTable->find()->toArray(); - $allRoleNames = array_column($allRoleNames, 'name'); - return array_combine($allRoleNames, $allRoleNames); - }, - 'dependsOn' => 'keycloak.enabled' - ], 'keycloak.screw' => [ 'name' => 'Screw', 'type' => 'string', diff --git a/src/Model/Table/UsersTable.php b/src/Model/Table/UsersTable.php index 647d32f..a2ebfeb 100644 --- a/src/Model/Table/UsersTable.php +++ b/src/Model/Table/UsersTable.php @@ -245,10 +245,6 @@ class UsersTable extends AppTable { $role = $this->Roles->find()->where(['name' => $user['role']['name']])->first(); if (empty($role)) { - if (!empty(Configure::read('keycloak.default_role_name'))) { - $default_role_name = Configure::read('keycloak.default_role_name'); - $role = $this->Roles->find()->where(['name' => $default_role_name])->first(); - } if (empty($role)) { throw new NotFoundException(__('Invalid role')); } From 6197b55ec921da42f50bace16ef524f9cbd8fba6 Mon Sep 17 00:00:00 2001 From: iglocska Date: Sun, 13 Nov 2022 11:23:23 +0100 Subject: [PATCH 39/43] chg: [user index] added column configuration --- templates/Users/index.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/templates/Users/index.php b/templates/Users/index.php index dd287c8..826ae5e 100644 --- a/templates/Users/index.php +++ b/templates/Users/index.php @@ -21,6 +21,9 @@ echo $this->element('genericElements/IndexTable/index_table', [ 'placeholder' => __('Enter value to search'), 'data' => '', 'searchKey' => 'value' + ], + [ + 'type' => 'table_action', ] ] ], From cc1839cbcf094e5f8b5b776ceda131abbdcb373f Mon Sep 17 00:00:00 2001 From: iglocska Date: Mon, 14 Nov 2022 11:07:52 +0100 Subject: [PATCH 40/43] fix: [kc] only try to set the signingin url to KC if KC is actually enabled --- templates/Users/view.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/templates/Users/view.php b/templates/Users/view.php index c9b3ea2..c6ca881 100644 --- a/templates/Users/view.php +++ b/templates/Users/view.php @@ -1,5 +1,7 @@ __('ID'), From f3023eb82c3e20ba2e72e57a608be76752838a38 Mon Sep 17 00:00:00 2001 From: iglocska Date: Mon, 14 Nov 2022 11:37:58 +0100 Subject: [PATCH 41/43] fix: [individual edit] permission check fix --- src/Controller/IndividualsController.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Controller/IndividualsController.php b/src/Controller/IndividualsController.php index 66271e3..bd3c852 100644 --- a/src/Controller/IndividualsController.php +++ b/src/Controller/IndividualsController.php @@ -81,9 +81,9 @@ class IndividualsController extends AppController } $currentUser = $this->ACL->getUser(); $validIndividualIds = []; - if ($currentUser['role']['perm_admin']) { + if (!$currentUser['role']['perm_admin']) { $validIndividualIds = $this->Individuals->getValidIndividualsToEdit($currentUser); - if (!isset($validIndividualIds[$id])) { + if (!in_array($id, $validIndividualIds)) { throw new NotFoundException(__('Invalid individual.')); } } From 7940eb065f607b45fe9aa50536ae73d3fdb6a9fe Mon Sep 17 00:00:00 2001 From: Sami Mokaddem Date: Mon, 14 Nov 2022 12:36:36 +0100 Subject: [PATCH 42/43] new: [command] Summary tool to collect changes done by nationalities Currently supporting individual, organisation and user --- src/Command/SummaryCommand.php | 231 +++++++++++++++++++++++++++++++++ 1 file changed, 231 insertions(+) create mode 100644 src/Command/SummaryCommand.php diff --git a/src/Command/SummaryCommand.php b/src/Command/SummaryCommand.php new file mode 100644 index 0000000..721d885 --- /dev/null +++ b/src/Command/SummaryCommand.php @@ -0,0 +1,231 @@ +setDescription('Create a summary for data associated to the passed nationality that has been modified.'); + $parser->addArgument('nationality', [ + 'short' => 'n', + 'help' => 'The organisation nationality.', + 'required' => false + ]); + $parser->addOption('days', [ + 'short' => 'd', + 'help' => 'The amount of days to look back in the logs', + 'default' => 7 + ]); + return $parser; + } + + public function execute(Arguments $args, ConsoleIo $io) + { + $this->__loadTables(); + $this->io = $io; + $nationality = $args->getArgument('nationality'); + $days = $args->getOption('days'); + if (!is_null($nationality)) { + $nationalities = [$nationality]; + } else { + $nationalities = $this->_fetchOrgNationalities(); + } + foreach ($nationalities as $nationality) { + $this->io->out(sprintf('Nationality: %s', $nationality)); + $this->_collectChangedForNationality($nationality, $days); + $this->io->out($io->nl(2)); + $this->io->hr(); + } + } + + protected function _collectChangedForNationality($nationality, $days) + { + $filename = sprintf('/tmp/%s.txt', $nationality); + $file_input = fopen($filename, 'w'); + $organisationIDsForNationality = $this->_fetchOrganisationsForNationality($nationality); + if (empty($organisationIDsForNationality)) { + $message = sprintf('No changes for organisations with nationality `%s`', $nationality); + fwrite($file_input, $message); + $this->io->warning($message); + return; + } + $userForOrg = $this->_fetchUserForOrg($organisationIDsForNationality); + $userID = Hash::extract($userForOrg, '{n}.id'); + $individualID = Hash::extract($userForOrg, '{n}.individual_id'); + + $message = 'Modified users:' . PHP_EOL; + fwrite($file_input, $message); + $this->io->out($message); + $logsUsers = $this->_fetchLogsForUsers($userID, $days); + $modifiedUsers = $this->_formatLogsForTable($logsUsers); + foreach ($modifiedUsers as $row) { + fputcsv($file_input, $row); + } + $this->io->helper('Table')->output($modifiedUsers); + + $message = PHP_EOL . 'Modified organisations:' . PHP_EOL; + fwrite($file_input, $message); + $this->io->out($message); + $logsOrgs = $this->_fetchLogsForOrgs($organisationIDsForNationality, $days); + $modifiedOrgs = $this->_formatLogsForTable($logsOrgs); + foreach ($modifiedOrgs as $row) { + fputcsv($file_input, $row); + } + $this->io->helper('Table')->output($modifiedOrgs); + + $message = PHP_EOL . 'Modified individuals:' . PHP_EOL; + fwrite($file_input, $message); + $this->io->out($message); + $logsIndividuals = $this->_fetchLogsForIndividuals($individualID, $days); + $modifiedIndividuals = $this->_formatLogsForTable($logsIndividuals); + foreach ($modifiedIndividuals as $row) { + fputcsv($file_input, $row); + } + $this->io->helper('Table')->output($modifiedIndividuals); + fclose($file_input); + } + + private function __loadTables() + { + $tables = ['Users', 'Organisations', 'Individuals', 'AuditLogs']; + foreach ($tables as $table) { + $this->loadModel($table); + } + } + + protected function _fetchOrganisationsForNationality(string $nationality): array + { + return array_keys($this->Organisations->find('list') + ->where([ + 'nationality' => $nationality, + ]) + ->all() + ->toArray()); + } + + protected function _fetchOrgNationalities(): array + { + return $this->Organisations->find() + ->where([ + 'nationality !=' => '', + ]) + ->all() + ->extract('nationality') + ->toList(); + } + + protected function _fetchUserForOrg(array $orgIDs = []): array + { + if (empty($orgIDs)) { + return []; + } + return $this->Users->find() + ->contain(['Individuals', 'Roles', 'UserSettings', 'Organisations']) + ->where([ + 'Organisations.id IN' => $orgIDs, + ]) + ->enableHydration(false) + ->all()->toList(); + } + + protected function _fetchLogsForUsers(array $userIDs = [], int $days=7): array + { + if (empty($userIDs)) { + return []; + } + return $this->_fetchLogs([ + 'contain' => ['Users'], + 'conditions' => [ + 'model' => 'Users', + 'request_action IN' => ['add', 'edit', 'delete'], + 'model_id IN' => $userIDs, + 'AuditLogs.created >=' => FrozenTime::now()->subDays($days), + ] + ]); + } + + protected function _fetchLogsForOrgs(array $orgIDs = [], int $days = 7): array + { + if (empty($orgIDs)) { + return []; + } + return $this->_fetchLogs([ + 'contain' => ['Users'], + 'conditions' => [ + 'model' => 'Organisations', + 'request_action IN' => ['add', 'edit', 'delete'], + 'model_id IN' => $orgIDs, + 'AuditLogs.created >=' => FrozenTime::now()->subDays($days), + ] + ]); + } + + protected function _fetchLogsForIndividuals(array $individualID = [], int $days = 7): array + { + if (empty($individualID)) { + return []; + } + return $this->_fetchLogs([ + 'contain' => ['Users'], + 'conditions' => [ + 'model' => 'Individuals', + 'request_action IN' => ['add', 'edit', 'delete'], + 'model_id IN' => $individualID, + 'AuditLogs.created >=' => FrozenTime::now()->subDays($days), + ] + ]); + } + + protected function _fetchLogs(array $options=[]): array + { + $logs = $this->AuditLogs->find() + ->contain($options['contain']) + ->where($options['conditions']) + ->enableHydration(false) + ->all()->toList(); + return array_map(function ($log) { + $log['changed'] = is_resource($log['changed']) ? stream_get_contents($log['changed']) : $log['changed']; + $log['changed'] = json_decode($log['changed']); + return $log; + }, $logs); + } + + protected function _formatLogsForTable($logEntries): array + { + $header = ['Model', 'Action', 'Editor user', 'Log ID', 'Datetime', 'Change']; + $data = [$header]; + foreach ($logEntries as $logEntry) { + $formatted = [ + $logEntry['model'], + $logEntry['request_action'], + sprintf('%s (%s)', $logEntry['user']['username'], $logEntry['user_id']), + $logEntry['id'], + $logEntry['created']->i18nFormat('yyyy-MM-dd HH:mm:ss'), + ]; + if ($logEntry['request_action'] == 'edit') { + $formatted[] = json_encode($logEntry['changed'], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + } else { + $formatted[] = json_encode($logEntry['changed'], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + } + $data[] = $formatted; + } + return $data; + } +} From cbe2f7f68438b8c2d21499913191ec1b244932d6 Mon Sep 17 00:00:00 2001 From: iglocska Date: Mon, 14 Nov 2022 12:38:45 +0100 Subject: [PATCH 43/43] chg: [version] bump --- src/VERSION.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/VERSION.json b/src/VERSION.json index 1fe70b7..a51d7e0 100644 --- a/src/VERSION.json +++ b/src/VERSION.json @@ -1,4 +1,4 @@ { - "version": "1.7", + "version": "1.8", "application": "Cerebrate" }