diff --git a/config/Migrations/20211001000000_RolesPermOrgAdmin.php b/config/Migrations/20211001000000_RolesPermOrgAdmin.php new file mode 100644 index 0000000..28cfae8 --- /dev/null +++ b/config/Migrations/20211001000000_RolesPermOrgAdmin.php @@ -0,0 +1,18 @@ +table('roles') + ->addColumn('perm_org_admin', 'boolean', [ + 'default' => 0, + 'null' => false, + ]) + ->update(); + } +} diff --git a/src/Controller/Component/CRUDComponent.php b/src/Controller/Component/CRUDComponent.php index 2c4704d..5d91903 100644 --- a/src/Controller/Component/CRUDComponent.php +++ b/src/Controller/Component/CRUDComponent.php @@ -140,8 +140,14 @@ class CRUDComponent extends Component $patchEntityParams['fields'] = $params['fields']; } $data = $this->Table->patchEntity($data, $input, $patchEntityParams); + if (isset($params['beforeSave'])) { + $data = $params['beforeSave']($data); + } $savedData = $this->Table->save($data); if ($savedData !== false) { + if (isset($params['afterSave'])) { + $params['afterSave']($data); + } $message = __('{0} added.', $this->ObjectAlias); if (!empty($input['metaFields'])) { $this->saveMetaFields($data->id, $input); @@ -257,8 +263,14 @@ class CRUDComponent extends Component $patchEntityParams['fields'] = $params['fields']; } $data = $this->Table->patchEntity($data, $input, $patchEntityParams); + if (isset($params['beforeSave'])) { + $data = $params['beforeSave']($data); + } $savedData = $this->Table->save($data); if ($savedData !== false) { + if (isset($params['afterSave'])) { + $params['afterSave']($data); + } $message = __('{0} `{1}` updated.', $this->ObjectAlias, $savedData->{$this->Table->getDisplayField()}); if (!empty($input['metaFields'])) { $this->MetaFields->deleteAll(['scope' => $this->Table->metaFields, 'parent_id' => $savedData->id]); diff --git a/src/Controller/RolesController.php b/src/Controller/RolesController.php index c139480..b6f59d0 100644 --- a/src/Controller/RolesController.php +++ b/src/Controller/RolesController.php @@ -15,7 +15,7 @@ class RolesController extends AppController public function index() { $this->CRUD->index([ - 'filters' => ['name', 'uuid', 'perm_admin', 'Users.id'], + 'filters' => ['name', 'uuid', 'perm_admin', 'perm_org_admin', 'Users.id'], 'quickFilters' => ['name'] ]); $responsePayload = $this->CRUD->getResponsePayload(); diff --git a/src/Controller/UsersController.php b/src/Controller/UsersController.php index 22a7474..3a8f9e6 100644 --- a/src/Controller/UsersController.php +++ b/src/Controller/UsersController.php @@ -25,7 +25,12 @@ class UsersController extends AppController public function add() { - $this->CRUD->add(); + $this->CRUD->add([ + 'beforeSave' => function($data) { + $this->Users->enrollUserRouter($data); + return $data; + } + ]); $responsePayload = $this->CRUD->getResponsePayload(); if (!empty($responsePayload)) { return $responsePayload; diff --git a/src/Event/SocialAuthListener.php b/src/Event/SocialAuthListener.php new file mode 100644 index 0000000..2bfba76 --- /dev/null +++ b/src/Event/SocialAuthListener.php @@ -0,0 +1,89 @@ + 'afterIdentify', + SocialAuthMiddleware::EVENT_BEFORE_REDIRECT => 'beforeRedirect', + // Uncomment below if you want to use the event listener to return + // an entity for a new user instead of directly using `createUser()` table method. + // SocialAuthMiddleware::EVENT_CREATE_USER => 'createUser', + ]; + } + + public function afterIdentify(EventInterface $event, EntityInterface $user): EntityInterface + { + // Update last login time + // $user->set('last_login', new FrozenTime()); + + // You can access the profile using $user->social_profile + + $this->getTableLocator()->get('Users')->saveOrFail($user); + + return $user; + } + + /** + * @param \Cake\Event\EventInterface $event + * @param string|array $url + * @param string $status + * @param \Cake\Http\ServerRequest $request + * @return void + */ + public function beforeRedirect(EventInterface $event, $url, string $status, ServerRequest $request): void + { + $messages = (array)$request->getSession()->read('Flash.flash'); + + // Set flash message + switch ($status) { + case SocialAuthMiddleware::AUTH_STATUS_SUCCESS: + $messages[] = [ + 'message' => __('You are now logged in'), + 'key' => 'flash', + 'element' => 'flash/success', + 'params' => [], + ]; + break; + + // Auth through provider failed. Details will be logged in + // `error.log` if `logErrors` option is set to `true`. + case SocialAuthMiddleware::AUTH_STATUS_PROVIDER_FAILURE: + + // Table finder failed to return user record. An e.g. of this is a + // user has been authenticated through provider but your finder has + // a condition to not return an inactivated user. + case SocialAuthMiddleware::AUTH_STATUS_FINDER_FAILURE: + $messages[] = [ + 'message' => __('Authentication failed'), + 'key' => 'flash', + 'element' => 'flash/error', + 'params' => [], + ]; + break; + } + + $request->getSession()->write('Flash.flash', $messages); + + // You can return a modified redirect URL if needed. + } + + public function createUser(EventInterface $event, EntityInterface $profile, Session $session): EntityInterface + { + // Create and save entity for new user as shown in "createUser()" method above + + return $user; + } +} diff --git a/src/Model/Behavior/AuthKeycloakBehavior.php b/src/Model/Behavior/AuthKeycloakBehavior.php new file mode 100644 index 0000000..519794c --- /dev/null +++ b/src/Model/Behavior/AuthKeycloakBehavior.php @@ -0,0 +1,168 @@ +read('Auth.User.id'); + if ($userId) { + return $this->_table->get($userId); + } + + $raw_profile_payload = $profile->access_token->getJwt()->getPayload(); + $user = $this->extractProfileData($raw_profile_payload); + if (!$user) { + throw new \RuntimeException('Unable to save new user'); + } + + return $user; + } + + private function extractProfileData($profile_payload) + { + $mapping = Configure::read('keycloak.mapping'); + $fields = [ + 'org_uuid' => 'org_uuid', + 'role_name' => 'role_name', + 'username' => 'preferred_username', + 'email' => 'email', + 'first_name' => 'given_name', + 'last_name' => 'family_name' + ]; + foreach ($fields as $field => $default) { + if (!empty($mapping[$field])) { + $fields[$field] = $mapping[$field]; + } + } + $user = [ + 'individual' => [ + 'email' => $profile_payload[$fields['email']], + 'first_name' => $profile_payload[$fields['first_name']], + 'last_name' => $profile_payload[$fields['last_name']] + ], + 'user' => [ + 'username' => $profile_payload[$fields['username']], + ], + 'organisation' => [ + 'uuid' => $profile_payload[$fields['org_uuid']], + ], + 'role' => [ + 'name' => $profile_payload[$fields['role_name']], + ] + ]; + $user['user']['individual_id'] = $this->_table->captureIndividual($user); + $user['user']['role_id'] = $this->_table->captureRole($user); + $existingUser = $this->_table->find()->where(['username' => $user['user']['username']])->first(); + if (empty($existingUser)) { + $user['user']['password'] = Security::randomString(16); + $existingUser = $this->_table->newEntity($user['user']); + if (!$this->save($existingUser)) { + return false; + } + } else { + $dirty = false; + if ($user['user']['individual_id'] != $existingUser['individual_id']) { + $existingUser['individual_id'] = $user['user']['individual_id']; + $dirty = true; + } + if ($user['user']['role_id'] != $existingUser['role_id']) { + $existingUser['role_id'] = $user['user']['role_id']; + $dirty = true; + } + $existingUser; + if ($dirty) { + if (!$this->_table->save($existingUser)) { + return false; + } + } + } + return $existingUser; + } + + public function enrollUser($data): bool + { + $individual = $this->_table->Individuals->find()->where( + ['id' => $data['individual_id']] + )->contain(['Organisations'])->first(); + $roleConditions = [ + 'id' => $data['role_id'] + ]; + if (!empty(Configure::read('keycloak.user_management.actions'))) { + $roleConditions['name'] = Configure::read('keycloak.default_role_name'); + } + $role = $this->_table->Roles->find()->where($roleConditions)->first(); + $orgs = []; + foreach ($individual['organisations'] as $org) { + $orgs[] = $org['uuid']; + } + $token = $this->getAdminAccessToken(); + $keyCloakUser = [ + 'firstName' => $individual['first_name'], + 'lastName' => $individual['last_name'], + 'username' => $data['username'], + 'email' => $individual['email'], + 'attributes' => [ + 'role_name' => empty($role['name']) ? Configure::read('keycloak.default_role_name') : $role['name'], + 'org_uuid' => empty($orgs[0]) ? '' : $orgs[0] + ] + ]; + $keycloakConfig = Configure::read('keycloak'); + $http = new Client(); + $url = sprintf( + '%s/admin/realms/%s/users', + $keycloakConfig['provider']['baseUrl'], + $keycloakConfig['provider']['realm'] + ); + $response = $http->post( + $url, + json_encode($keyCloakUser), + [ + 'headers' => [ + 'Content-Type' => 'application/json', + 'Authorization' => 'Bearer ' . $token + ] + ] + ); + return true; + } + + private function getAdminAccessToken() + { + $keycloakConfig = Configure::read('keycloak'); + $http = new Client(); + $tokenUrl = sprintf( + '%s/realms/%s/protocol/openid-connect/token', + $keycloakConfig['provider']['baseUrl'], + $keycloakConfig['provider']['realm'] + ); + $response = $http->post( + $tokenUrl, + sprintf( + 'grant_type=client_credentials&client_id=%s&client_secret=%s', + urlencode(Configure::read('keycloak.provider.applicationId')), + urlencode(Configure::read('keycloak.provider.applicationSecret')) + ), + [ + 'headers' => [ + 'Content-Type' => 'application/x-www-form-urlencoded' + ] + ] + ); + $parsedResponse = json_decode($response->getStringBody(), true); + return $parsedResponse['access_token']; + } +} diff --git a/src/Model/Table/UsersTable.php b/src/Model/Table/UsersTable.php index e8f8812..585295d 100644 --- a/src/Model/Table/UsersTable.php +++ b/src/Model/Table/UsersTable.php @@ -19,6 +19,7 @@ class UsersTable extends AppTable { parent::initialize($config); $this->addBehavior('UUID'); + $this->initAuthBehaviors(); $this->belongsTo( 'Individuals', [ @@ -36,6 +37,13 @@ class UsersTable extends AppTable $this->setDisplayField('username'); } + private function initAuthBehaviors() + { + if (!empty(Configure::read('keycloak'))) { + $this->addBehavior('AuthKeycloak'); + } + } + public function validationDefault(Validator $validator): Validator { $validator @@ -99,68 +107,7 @@ class UsersTable extends AppTable return true; } - private function extractKeycloakProfileData($profile_payload) - { - $mapping = Configure::read('keycloak.mapping'); - $fields = [ - 'org_uuid' => 'org_uuid', - 'role_name' => 'role_name', - 'username' => 'preferred_username', - 'email' => 'email', - 'first_name' => 'given_name', - 'last_name' => 'family_name' - ]; - foreach ($fields as $field => $default) { - if (!empty($mapping[$field])) { - $fields[$field] = $mapping[$field]; - } - } - $user = [ - 'individual' => [ - 'email' => $profile_payload[$fields['email']], - 'first_name' => $profile_payload[$fields['first_name']], - 'last_name' => $profile_payload[$fields['last_name']] - ], - 'user' => [ - 'username' => $profile_payload[$fields['username']], - ], - 'organisation' => [ - 'uuid' => $profile_payload[$fields['org_uuid']], - ], - 'role' => [ - 'name' => $profile_payload[$fields['role_name']], - ] - ]; - $user['user']['individual_id'] = $this->captureIndividual($user); - $user['user']['role_id'] = $this->captureRole($user); - $existingUser = $this->find()->where(['username' => $user['user']['username']])->first(); - if (empty($existingUser)) { - $user['user']['password'] = Security::randomString(16); - $existingUser = $this->newEntity($user['user']); - if (!$this->save($existingUser)) { - return false; - } - } else { - $dirty = false; - if ($user['user']['individual_id'] != $existingUser['individual_id']) { - $existingUser['individual_id'] = $user['user']['individual_id']; - $dirty = true; - } - if ($user['user']['role_id'] != $existingUser['role_id']) { - $existingUser['role_id'] = $user['user']['role_id']; - $dirty = true; - } - $existingUser; - if ($dirty) { - if (!$this->save($existingUser)) { - return false; - } - } - } - return $existingUser; - } - - private function captureIndividual($user) + public function captureIndividual($user): int { $individual = $this->Individuals->find()->where(['email' => $user['individual']['email']])->first(); if (empty($individual)) { @@ -172,7 +119,7 @@ class UsersTable extends AppTable return $individual->id; } - private function captureOrganisation($user) + public function captureOrganisation($user): int { $organisation = $this->Organisations->find()->where(['uuid' => $user['organisation']['uuid']])->first(); if (empty($organisation)) { @@ -185,7 +132,7 @@ class UsersTable extends AppTable return $organisation->id; } - private function captureRole($user) + public function captureRole($user): int { $role = $this->Roles->find()->where(['name' => $user['role']['name']])->first(); if (empty($role)) { @@ -200,19 +147,10 @@ class UsersTable extends AppTable return $role->id; } - public function getUser(EntityInterface $profile, Session $session) + public function enrollUserRouter($data): void { - $userId = $session->read('Auth.User.id'); - if ($userId) { - return $this->get($userId); + if (!empty(Configure::read('keycloak'))) { + $this->enrollUser($data); } - - $raw_profile_payload = $profile->access_token->getJwt()->getPayload(); - $user = $this->extractKeycloakProfileData($raw_profile_payload); - if (!$user) { - throw new \RuntimeException('Unable to save new user'); - } - - return $user; } } diff --git a/templates/Roles/add.php b/templates/Roles/add.php index 3d874b5..c709865 100644 --- a/templates/Roles/add.php +++ b/templates/Roles/add.php @@ -12,6 +12,11 @@ 'type' => 'checkbox', 'label' => 'Full admin privilege' ], + [ + 'field' => 'perm_org_admin', + 'type' => 'checkbox', + 'label' => 'Organisation admin privilege' + ], [ 'field' => 'perm_sync', 'type' => 'checkbox', diff --git a/templates/Roles/index.php b/templates/Roles/index.php index e5685c3..2fc4c03 100644 --- a/templates/Roles/index.php +++ b/templates/Roles/index.php @@ -47,6 +47,12 @@ echo $this->element('genericElements/IndexTable/index_table', [ 'data_path' => 'perm_admin', 'element' => 'boolean' ], + [ + 'name' => __('Org Admin'), + 'sort' => 'perm_org_admin', + 'data_path' => 'perm_org_admin', + 'element' => 'boolean' + ], [ 'name' => __('Sync'), 'sort' => 'perm_sync', diff --git a/templates/Roles/view.php b/templates/Roles/view.php index 4624868..eac0175 100644 --- a/templates/Roles/view.php +++ b/templates/Roles/view.php @@ -17,6 +17,11 @@ echo $this->element( 'path' => 'perm_admin', 'type' => 'boolean' ], + [ + 'key' => __('Organisation admin permission'), + 'path' => 'perm_org_admin', + 'type' => 'boolean' + ], [ 'key' => __('Sync permission'), 'path' => 'perm_sync',