Merge branch 'develop' of github.com:cerebrate-project/cerebrate into feature-metafield-dropdown

pull/121/head
Sami Mokaddem 2022-11-14 14:53:02 +01:00
commit b53f2681b4
No known key found for this signature in database
GPG Key ID: 164C473F627A06FA
40 changed files with 1428 additions and 279 deletions

160
INSTALL/INSTALL-rhel.md Normal file
View File

@ -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](<https://access.redhat.com/documentation/en-us/red_hat_enterprise_linux/8/html/using_selinux/configuring-selinux-for-applications-and-services-with-non-standard-configurations_using-selinux>)
>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
```

View File

@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
use Migrations\AbstractMigration;
final class PermissionRestrictions extends AbstractMigration
{
/**
* Change Method.
*
* Write your reversible migrations using this method.
*
* More information on writing migrations is available here:
* https://book.cakephp.org/phinx/0/en/migrations.html#the-change-method
*
* Remember to call "create()" or "update()" and NOT "save()" when working
* with the Table class.
*/
public function change(): void
{
$exists = $this->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();
}
}
}

View File

@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
use Migrations\AbstractMigration;
use Phinx\Db\Adapter\MysqlAdapter;
final class SupportForMegalomaniacPgpKeys extends AbstractMigration
{
/**
* Change Method.
*
* Write your reversible migrations using this method.
*
* More information on writing migrations is available here:
* https://book.cakephp.org/phinx/0/en/migrations.html#the-change-method
*
* Remember to call "create()" or "update()" and NOT "save()" when working
* with the Table class.
*/
public function change(): void
{
$encryption_keys = $this->table('encryption_keys');
$encryption_keys->changeColumn('encryption_key', 'text', ['limit' => MysqlAdapter::TEXT_MEDIUM])->save();
}
}

View File

@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
use Migrations\AbstractMigration;
use Phinx\Db\Adapter\MysqlAdapter;
final class SupportForMegalomaniacPgpKeysRound2 extends AbstractMigration
{
/**
* Change Method.
*
* Write your reversible migrations using this method.
*
* More information on writing migrations is available here:
* https://book.cakephp.org/phinx/0/en/migrations.html#the-change-method
*
* Remember to call "create()" or "update()" and NOT "save()" when working
* with the Table class.
*/
public function change(): void
{
$encryption_keys = $this->table('audit_logs');
$encryption_keys->changeColumn('changed', 'blob', ['limit' => MysqlAdapter::BLOB_MEDIUM])->save();
}
}

View File

@ -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"
}
]
}

View File

@ -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, '<text-right>' . $v . '</text-right>'];
$tableData[] = [$k, '<text-right>' . count($v) . '</text-right>', '<text-right>' . implode(', ', $v) . '</text-right>'];
}
$io->out(__('Sync done. See the results below.'));
$io->helper('Table')->output($tableData);

View File

@ -0,0 +1,231 @@
<?php
/**
*
*
*/
namespace App\Command;
use Cake\Console\Command;
use Cake\Console\Arguments;
use Cake\Console\ConsoleIo;
use Cake\Console\ConsoleOptionParser;
use Cake\Filesystem\File;
use Cake\Utility\Hash;
use Cake\Utility\Text;
use Cake\Validation\Validator;
use Cake\Http\Client;
use Cake\I18n\FrozenTime;
class SummaryCommand extends Command
{
protected function buildOptionParser(ConsoleOptionParser $parser): ConsoleOptionParser
{
$parser->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;
}
}

View File

@ -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'],
@ -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'],

View File

@ -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();
}

View File

@ -76,6 +76,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) {
@ -101,6 +104,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()) {
@ -560,7 +569,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);
@ -673,12 +689,15 @@ class CRUDComponent extends Component
if (!empty($pruneEmptyDisabled) && !$metaTemplate->enabled) {
unset($metaTemplates[$i]);
}
continue;
}
$newestTemplate = $this->MetaTemplates->getNewestVersion($metaTemplate);
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;
}
@ -793,12 +812,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);

View File

@ -123,6 +123,11 @@ class Sidemenu {
'url' => '/auditLogs/index',
'icon' => 'history',
],
'PermissionLimitations' => [
'label' => __('Permission Limitations'),
'url' => '/permissionLimitations/index',
'icon' => 'jedi',
],
]
],
'API' => [

View File

@ -20,17 +20,30 @@ 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,
'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)) {
return $responsePayload;
}
$editableIds = null;
if ($orgAdmin) {
$editableIds = $this->Individuals->getValidIndividualsToEdit($currentUser);
}
$this->set('editableIds', $editableIds);
$this->set('alignmentScope', 'individuals');
}
@ -59,7 +72,29 @@ class IndividualsController extends AppController
public function edit($id)
{
$this->CRUD->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']) {
$validIndividualIds = $this->Individuals->getValidIndividualsToEdit($currentUser);
if (!in_array($id, $validIndividualIds)) {
throw new NotFoundException(__('Invalid individual.'));
}
}
$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;

View File

@ -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)) {

View File

@ -0,0 +1,91 @@
<?php
namespace App\Controller;
use App\Controller\AppController;
use Cake\Utility\Hash;
use Cake\Utility\Text;
use \Cake\Database\Expression\QueryExpression;
use Cake\Http\Exception\NotFoundException;
use Cake\Http\Exception\MethodNotAllowedException;
use Cake\Http\Exception\ForbiddenException;
class PermissionLimitationsController extends AppController
{
public $filterFields = ['scope', 'permission'];
public $quickFilterFields = ['name'];
public $containFields = [];
public function index()
{
$this->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');
}
}

View File

@ -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();
@ -136,7 +140,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)) {
@ -278,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;
@ -354,6 +367,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'));
}
}

View File

@ -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;
}

View File

@ -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);
}
@ -86,14 +84,67 @@ 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 = [
'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,
@ -110,12 +161,8 @@ class AuthKeycloakBehavior extends Behavior
)->first()
];
$clientId = $this->getClientId();
$roles = $this->getAllRoles($clientId);
$rolesParsed = [];
foreach ($roles as $role) {
$rolesParsed[$role['name']] = $role['id'];
}
if (!$this->createUser($user, $clientId, $rolesParsed)) {
$newUserId = $this->createUser($user, $clientId);
if (!$newUserId) {
$logChange = [
'username' => $user['username'],
'individual_id' => $user['individual']['id'],
@ -141,6 +188,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;
}
@ -149,9 +211,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']
@ -166,7 +228,19 @@ class AuthKeycloakBehavior extends Behavior
$users = [$user->toArray()];
$clientId = $this->getClientId();
$changes = $this->syncUsers($users, $clientId);
return !empty($changes);
return $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()
@ -208,9 +282,8 @@ class AuthKeycloakBehavior extends Behavior
public function syncWithKeycloak(): array
{
$this->updateMappers();
$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',
@ -228,86 +301,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'],
@ -315,26 +317,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;
@ -346,13 +350,23 @@ 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']
]
];
$response = $this->restApiRequest('%s/admin/realms/%s/users/' . $keycloakUser['id'], $change, 'put');
if (!$response->isOk()) {
@ -373,14 +387,20 @@ class AuthKeycloakBehavior extends Behavior
return false;
}
private function createUser(array $user, string $clientId, array $rolesParsed): bool
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']
]
];
$response = $this->restApiRequest('%s/admin/realms/%s/users', $newUser, 'post');
if (!$response->isOk()) {
@ -408,119 +428,78 @@ class AuthKeycloakBehavior extends Behavior
$users[0]['id'] = $users[0]['id'][0];
}
$user['id'] = $users[0]['id'];
$this->assignRolesToUser($user, $rolesParsed, $clientId);
return true;
}
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;
return $user['id'];
}
private function urlencodeEscapeForSprintf(string $input): string
{
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
]
];
}
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()) {
return false;
}
}
return true;
}
}

View File

@ -68,7 +68,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;
}
@ -77,14 +81,47 @@ 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;
}
}
}
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']
];
}
}
}

View File

@ -7,4 +7,9 @@ use Cake\ORM\Entity;
class EncryptionKey extends AppModel
{
public function rearrangeForAPI(): void
{
$this->rearrangeSimplify(['organisation', 'individual']);
}
}

View File

@ -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);
}
}
}

View File

@ -0,0 +1,11 @@
<?php
namespace App\Model\Entity;
use App\Model\Entity\AppModel;
use Cake\ORM\Entity;
class PermissionLimitation extends AppModel
{
}

View File

@ -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'];
@ -48,4 +48,39 @@ class User extends AppModel
return (new DefaultPasswordHasher())->hash($password);
}
}
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;
}
}

View File

@ -110,4 +110,17 @@ class IndividualsTable extends AppTable
}
return $query->group(['Individuals.id', 'Individuals.uuid']);
}
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,
'role_id NOT IN' => array_keys($adminRoles)
]
)->all()->toArray();
return array_keys($validIndividualIds);
}
}

View File

@ -0,0 +1,119 @@
<?php
namespace App\Model\Table;
use App\Model\Table\AppTable;
use Cake\ORM\Table;
use Cake\Validation\Validator;
use Cake\Error\Debugger;
use Cake\ORM\TableRegistry;
class PermissionLimitationsTable extends AppTable
{
public function initialize(array $config): void
{
parent::initialize($config);
$this->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'] = '<i class="fas fa-' . ((bool)$metaField['value'] ? 'check' : 'times') . '"></i>';
$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(
' <span title="%s"><span class="text-dark"><i class="fas fa-%s"></i>: </span>%s/%s</span>',
$altText,
$icons[$scope],
$value['current'],
$value['limit']
);
}
}
}
}
}
}
return $data;
}
}

View File

@ -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',
@ -284,6 +263,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'
]
]
]
],

View File

@ -23,6 +23,7 @@ class UsersTable extends AppTable
parent::initialize($config);
$this->addBehavior('Timestamp');
$this->addBehavior('UUID');
$this->addBehavior('MetaFields');
$this->addBehavior('AuditLog');
$this->addBehavior('NotifyAdmins', [
'fields' => ['role_id', 'individual_id', 'organisation_id', 'disabled', 'modified'],
@ -61,7 +62,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)
@ -69,9 +72,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'))) {
@ -82,6 +127,7 @@ class UsersTable extends AppTable
public function validationDefault(Validator $validator): Validator
{
$validator
->setStopOnFailure()
->requirePresence(['password'], 'create')
->add('password', [
'password_complexity' => [
@ -202,10 +248,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'));
}

View File

@ -1,4 +1,4 @@
{
"version": "1.6",
"version": "1.8",
"application": "Cerebrate"
}

View File

@ -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(' <span class="text-%s">%s</span>', $message_type, $field[$message_type]);
}
}
return $this->genNode('td', [
'class' => [
'col-8 col-sm-10',

View File

@ -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(

View File

@ -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',
@ -81,12 +87,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'];
}
]
],
]
]

View File

@ -0,0 +1,38 @@
<?php
echo $this->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')
]
]
]);
?>
</div>

View File

@ -0,0 +1,79 @@
<?php
echo $this->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 '</div>';
?>

View File

@ -0,0 +1,30 @@
<?php
echo $this->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' => []
]
);

View File

@ -21,6 +21,9 @@ echo $this->element('genericElements/IndexTable/index_table', [
'placeholder' => __('Enter value to search'),
'data' => '',
'searchKey' => 'value'
],
[
'type' => 'table_action',
]
]
],

View File

@ -1,5 +1,7 @@
<?php
$kcurl = $keycloakConfig['provider']['baseUrl'] . '/realms/' . $keycloakConfig['provider']['realm'] . '/account/#/security/signingin';
if (!empty($keycloakConfig['enabled'])) {
$kcurl = $keycloakConfig['provider']['baseUrl'] . '/realms/' . $keycloakConfig['provider']['realm'] . '/account/#/security/signingin';
}
$fields = [
[
'key' => __('ID'),

View File

@ -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,

View File

@ -41,6 +41,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',
@ -72,6 +75,9 @@ foreach ($metaTemplate->meta_template_fields as $metaTemplateField) {
if ($metaTemplateField->formType === 'dropdown') {
$fieldData = array_merge_recursive($fieldData, $metaTemplateField->formOptions);
}
// if ($metaTemplateField->type === 'boolean') {
// $fieldData['type'] = 'checkbox';
// }
$fieldsHtml .= $this->element(
'genericElements/Form/fieldScaffold',
[

View File

@ -1,11 +1,25 @@
<?php
if (!empty($row['user'])) {
$userId = $this->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('<br />', $links);
}
?>

View File

@ -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(' (<span class="text-%s">%s</span>)', $message_type, $field[$message_type]);
}
}
echo $string;

View File

@ -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;
}