Merge branch 'develop'

pull/116/head v1.7
iglocska 2022-11-09 14:15:28 +01:00
commit f69456d8f3
No known key found for this signature in database
GPG Key ID: BEA224F1FEF113AC
33 changed files with 846 additions and 232 deletions

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

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

@ -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()) {
@ -545,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);
@ -663,7 +679,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;
}

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

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

@ -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'])) {
@ -136,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)) {
@ -354,6 +359,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);
}
@ -110,12 +108,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 +158,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']
@ -182,7 +175,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()
@ -224,9 +229,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',
@ -244,86 +248,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'],
@ -331,26 +264,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;
@ -362,13 +297,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()) {
@ -389,14 +334,20 @@ 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']
]
];
$response = $this->restApiRequest('%s/admin/realms/%s/users', $newUser, 'post');
if (!$response->isOk()) {
@ -424,119 +375,78 @@ 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);
}
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

@ -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,14 +74,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,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);
}
}

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

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

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->initAuthBehaviors();
$this->belongsTo(
@ -58,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)
@ -66,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'))) {
@ -79,6 +124,7 @@ class UsersTable extends AppTable
public function validationDefault(Validator $validator): Validator
{
$validator
->setStopOnFailure()
->requirePresence(['password'], 'create')
->add('password', [
'password_complexity' => [

View File

@ -1,4 +1,4 @@
{
"version": "1.6",
"version": "1.7",
"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

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

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

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

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

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