diff --git a/application/Espo/Controllers/Stream.php b/application/Espo/Controllers/Stream.php index e4d35e23a1..3f7c04f79b 100644 --- a/application/Espo/Controllers/Stream.php +++ b/application/Espo/Controllers/Stream.php @@ -115,6 +115,30 @@ class Stream ]; } + /** + * @throws BadRequest + * @throws Forbidden + * @throws NotFound + */ + public function getActionListUpdates(Request $request): stdClass + { + $id = $request->getRouteParam('id'); + $scope = $request->getRouteParam('scope'); + + if ($scope === null || $id === null) { + throw new BadRequest(); + } + + $searchParams = $this->fetchSearchParams($request); + + $result = $this->service->findUpdates($scope, $id, $searchParams); + + return (object) [ + 'total' => $result->getTotal(), + 'list' => $result->getValueMapList(), + ]; + } + /** * @throws BadRequest * @throws Forbidden @@ -146,6 +170,20 @@ class Stream $searchParams = $searchParams->withBoolFilterAdded('skipOwn'); } + $beforeNumber = $request->getQueryParam('beforeNumber'); + + if ($beforeNumber) { + $searchParams = $searchParams + ->withWhereAdded( + WhereItem + ::createBuilder() + ->setAttribute('number') + ->setType(WhereItem\Type::LESS_THAN) + ->setValue($beforeNumber) + ->build() + ); + } + return $searchParams; } } diff --git a/application/Espo/Resources/i18n/en_US/Global.json b/application/Espo/Resources/i18n/en_US/Global.json index ac9d9ed5a1..a5622721ae 100644 --- a/application/Espo/Resources/i18n/en_US/Global.json +++ b/application/Espo/Resources/i18n/en_US/Global.json @@ -285,7 +285,9 @@ "Show Navigation Panel": "Show Navigation Panel", "Hide Navigation Panel": "Hide Navigation Panel", "Copy to Clipboard": "Copy to Clipboard", - "Copied to clipboard": "Copied to clipboard" + "Copied to clipboard": "Copied to clipboard", + "Audit Log": "Audit Log", + "View Audit Log": "View Audit Log" }, "messages": { "pleaseWait": "Please wait...", diff --git a/application/Espo/Resources/i18n/en_US/Role.json b/application/Espo/Resources/i18n/en_US/Role.json index 272d885b4f..1cc1e4bb3d 100644 --- a/application/Espo/Resources/i18n/en_US/Role.json +++ b/application/Espo/Resources/i18n/en_US/Role.json @@ -11,6 +11,7 @@ "massUpdatePermission": "Mass Update Permission", "followerManagementPermission": "Follower Management Permission", "dataPrivacyPermission": "Data Privacy Permission", + "auditPermission": "Audit Permission", "data": "Data", "fieldData": "Field Data" }, @@ -27,7 +28,8 @@ "exportPermission": "Allows to export records.", "massUpdatePermission": "The ability to perform mass update of records.", "followerManagementPermission": "Allows to manage followers of specific records.", - "dataPrivacyPermission": "Allows to view and erase personal data." + "dataPrivacyPermission": "Allows to view and erase personal data.", + "auditPermission": "Allows to view the audit log." }, "labels": { "Access": "Access", diff --git a/application/Espo/Resources/layouts/Role/detail.json b/application/Espo/Resources/layouts/Role/detail.json index ac8b833d31..601179beea 100644 --- a/application/Espo/Resources/layouts/Role/detail.json +++ b/application/Espo/Resources/layouts/Role/detail.json @@ -24,6 +24,11 @@ {"name": "massUpdatePermission"}, {"name": "followerManagementPermission"}, {"name": "messagePermission"} + ], + [ + {"name": "auditPermission"}, + false, + false ] ] } diff --git a/application/Espo/Resources/metadata/app/acl.json b/application/Espo/Resources/metadata/app/acl.json index 24bc9143d7..06adbae10b 100644 --- a/application/Espo/Resources/metadata/app/acl.json +++ b/application/Espo/Resources/metadata/app/acl.json @@ -161,7 +161,8 @@ "exportPermission", "massUpdatePermission", "followerManagementPermission", - "dataPrivacyPermission" + "dataPrivacyPermission", + "auditPermission" ], "valuePermissionHighestLevels": { "assignmentPermission": "all", @@ -172,7 +173,8 @@ "exportPermission": "yes", "massUpdatePermission": "yes", "followerManagementPermission": "all", - "dataPrivacyPermission": "yes" + "dataPrivacyPermission": "yes", + "auditPermission": "yes" }, "permissionsStrictDefaults": { "assignmentPermission": "no", @@ -183,7 +185,8 @@ "exportPermission": "no", "massUpdatePermission": "no", "followerManagementPermission": "no", - "dataPrivacyPermission": "no" + "dataPrivacyPermission": "no", + "auditPermission": "no" }, "scopeLevelTypesStrictDefaults": { "boolean": false, diff --git a/application/Espo/Resources/metadata/clientDefs/Global.json b/application/Espo/Resources/metadata/clientDefs/Global.json new file mode 100644 index 0000000000..2456676031 --- /dev/null +++ b/application/Espo/Resources/metadata/clientDefs/Global.json @@ -0,0 +1,11 @@ +{ + "detailActionList": [ + { + "name": "viewAuditLog", + "label": "View Audit Log", + "actionFunction": "show", + "checkVisibilityFunction": "isAvailable", + "handler": "handlers/record/view-audit-log" + } + ] +} diff --git a/application/Espo/Resources/metadata/entityDefs/Role.json b/application/Espo/Resources/metadata/entityDefs/Role.json index b04076608b..2e812ee449 100644 --- a/application/Espo/Resources/metadata/entityDefs/Role.json +++ b/application/Espo/Resources/metadata/entityDefs/Role.json @@ -78,6 +78,14 @@ "translation": "Role.options.levelList", "view": "views/role/fields/permission" }, + "auditPermission": { + "type": "enum", + "options": ["not-set", "yes", "no"], + "default": "not-set", + "tooltip": true, + "translation": "Role.options.levelList", + "view": "views/role/fields/permission" + }, "data": { "type": "jsonObject" }, diff --git a/application/Espo/Resources/routes.json b/application/Espo/Resources/routes.json index 8f3af70b63..8035a6b7f9 100644 --- a/application/Espo/Resources/routes.json +++ b/application/Espo/Resources/routes.json @@ -521,6 +521,16 @@ "scope": ":controller" } }, + { + "route": "/:controller/:id/updateStream", + "method": "get", + "params": { + "controller": "Stream", + "action": "listUpdates", + "id": ":id", + "scope": ":controller" + } + }, { "route": "/:controller/:id/subscription", "method": "put", diff --git a/application/Espo/Tools/Stream/HookProcessor.php b/application/Espo/Tools/Stream/HookProcessor.php index de9be7bd29..61d1701a10 100644 --- a/application/Espo/Tools/Stream/HookProcessor.php +++ b/application/Espo/Tools/Stream/HookProcessor.php @@ -76,10 +76,16 @@ class HookProcessor */ public function afterSave(Entity $entity, array $options): void { - if ($this->checkHasStream($entity->getEntityType())) { + $hasStream = $this->checkHasStream($entity->getEntityType()); + + if ($hasStream) { $this->afterSaveStream($entity, $options); } + if (!$hasStream) { + $this->afterSaveNoStream($entity, $options); + } + if ( $entity->isNew() && empty($options[self::OPTION_NO_STREAM]) && @@ -608,4 +614,22 @@ class HookProcessor // Add time period (a few minutes). If before, remove RELATE note, don't create 'unrelate' if before. } } + + /** + * @param array $options + */ + private function afterSaveNoStream(Entity $entity, array $options): void + { + if (!$entity instanceof CoreEntity) { + return; + } + + if (!empty($options[self::OPTION_NO_STREAM]) || !empty($options[SaveOption::SILENT])) { + return; + } + + if (!$entity->isNew()) { + $this->service->handleAudited($entity, $options); + } + } } diff --git a/application/Espo/Tools/Stream/RecordService.php b/application/Espo/Tools/Stream/RecordService.php index dd7ad1fcca..83a12569f4 100644 --- a/application/Espo/Tools/Stream/RecordService.php +++ b/application/Espo/Tools/Stream/RecordService.php @@ -33,6 +33,7 @@ use Espo\Core\Exceptions\BadRequest; use Espo\Core\Exceptions\Forbidden; use Espo\Core\Exceptions\NotFound; use Espo\Core\Select\SearchParams; +use Espo\Core\Utils\Metadata; use Espo\ORM\EntityManager; use Espo\Entities\User; use Espo\Entities\Note; @@ -52,7 +53,8 @@ class RecordService private Acl $acl, private NoteAccessControl $noteAccessControl, private Helper $helper, - private QueryHelper $queryHelper + private QueryHelper $queryHelper, + private Metadata $metadata ) {} /** @@ -79,6 +81,56 @@ class RecordService throw new Forbidden(); } + return $this->findInternal($scope, $id, $searchParams); + } + + /** + * Find a record stream records. + * + * @return RecordCollection + * @throws Forbidden + * @throws BadRequest + * @throws NotFound + */ + public function findUpdates(string $scope, string $id, SearchParams $searchParams): RecordCollection + { + if ($this->user->isPortal()) { + throw new Forbidden(); + } + + if ($this->acl->getPermissionLevel('audit') !== Table::LEVEL_YES) { + throw new Forbidden(); + } + + $entity = $this->entityManager->getEntityById($scope, $id); + + if (!$entity) { + throw new NotFound(); + } + + if (!$this->acl->checkEntityRead($entity)) { + throw new Forbidden(); + } + + if ($entity instanceof User && !$this->user->isAdmin()) { + throw new Forbidden(); + } + + $searchParams = $searchParams->withPrimaryFilter('updates'); + + return $this->findInternal($scope, $id, $searchParams); + } + + /** + * Find a record stream records. + * + * @return RecordCollection + * @throws Forbidden + * @throws BadRequest + * @throws NotFound + */ + private function findInternal(string $scope, string $id, SearchParams $searchParams): RecordCollection + { $builder = $this->queryHelper->buildBaseQueryBuilder($searchParams); $where = $this->user->isPortal() ? @@ -103,6 +155,7 @@ class RecordService $this->applyPortalAccess($builder, $where); $this->applyAccess($builder, $id, $scope, $where); $this->applyIgnore($where); + $this->applyStatusIgnore($scope, $where); $builder->where($where); @@ -321,4 +374,22 @@ class RecordService 'OR' => $orGroup, ]; } + + /** + * @param array $where + */ + private function applyStatusIgnore(string $scope, array &$where): void + { + $field = $this->metadata->get("scopes.$scope.statusField"); + + if (!$field) { + return; + } + + if ($this->acl->checkField($scope, $field)) { + return; + } + + $where[] = ['type!=' => Note::TYPE_STATUS]; + } } diff --git a/client/src/handlers/record/view-audit-log.js b/client/src/handlers/record/view-audit-log.js new file mode 100644 index 0000000000..adfccbad3f --- /dev/null +++ b/client/src/handlers/record/view-audit-log.js @@ -0,0 +1,74 @@ +/************************************************************************ + * This file is part of EspoCRM. + * + * EspoCRM – Open Source CRM application. + * Copyright (C) 2014-2024 Yurii Kuznietsov, Taras Machyshyn, Oleksii Avramenko + * Website: https://www.espocrm.com + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +import StreamViewAuditLogModalView from 'views/stream/modals/view-audit-log'; + +class ViewAuditLogHandler { + + constructor(/** import('views/record/detail').default */view) { + this.view = view; + this.metadata = /** @type {module:metadata} */view.getMetadata(); + this.entityType = this.view.entityType; + this.model = /** @type {module:model} */this.view.model; + + this.hasAudited = ( + this.metadata.get(`scopes.${this.entityType}.stream`) && + this.metadata.get(`scopes.${this.entityType}.statusField`) + ) || + this.model.getFieldList().find(field => this.model.getFieldParam(field, 'audited')) !== undefined; + + if (this.entityType === 'User' && !this.view.getUser().isAdmin()) { + this.hasAudited = false; + } + + if (this.view.getUser().isPortal()) { + this.hasAudited = false; + } + + if (this.view.getAcl().getPermissionLevel('audit') !== 'yes') { + this.hasAudited = false; + } + } + + isAvailable() { + return this.hasAudited; + } + + show() { + const view = new StreamViewAuditLogModalView({model: this.model}); + + this.view.assignView('dialog', view) + .then(() => { + view.render(); + }) + } +} + +// noinspection JSUnusedGlobalSymbols +export default ViewAuditLogHandler; + diff --git a/client/src/views/stream/modals/view-audit-log.js b/client/src/views/stream/modals/view-audit-log.js new file mode 100644 index 0000000000..86ecf995d9 --- /dev/null +++ b/client/src/views/stream/modals/view-audit-log.js @@ -0,0 +1,90 @@ +/************************************************************************ + * This file is part of EspoCRM. + * + * EspoCRM – Open Source CRM application. + * Copyright (C) 2014-2024 Yurii Kuznietsov, Taras Machyshyn, Oleksii Avramenko + * Website: https://www.espocrm.com + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +import ModalView from 'views/modal'; +import ListStreamRecordView from 'views/stream/record/list'; +import $ from 'jquery'; + +class StreamViewAuditLogModalView extends ModalView { + + templateContent = '
{{{record}}}
' + + backdrop = true + + /** + * @param {{model: import('model').default}} options + */ + constructor(options) { + super(options); + } + + setup() { + const name = this.model.get('name') || this.model.id; + + this.$header = $('') + .append( + $('').text(name), + ' ', + $('').text(this.translate('Audit Log')) + ); + + this.buttonList = [ + { + name: 'close', + label: 'Close', + onClick: dialog => { + dialog.close(); + }, + } + ]; + + this.wait( + this.getCollectionFactory().create('Note').then(collection => { + collection.url = `${this.model.entityType}/${this.model.id}/updateStream`; + collection.maxSize = this.getConfig().get('recordsPerPage'); + + const listView = new ListStreamRecordView({ + collection: collection, + model: this.model, + // Prevents 'No Data' being displayed. + skipBuildRows: true, + }); + + Espo.Ui.notify(' ... '); + + return this.assignView('record', listView, '.record') + .then(() => { + collection.fetch() + .then(() => Espo.Ui.notify(false)); + }); + }) + ); + } +} + +export default StreamViewAuditLogModalView; diff --git a/tests/integration/Espo/Stream/AuditTest.php b/tests/integration/Espo/Stream/AuditTest.php new file mode 100644 index 0000000000..7d1261becb --- /dev/null +++ b/tests/integration/Espo/Stream/AuditTest.php @@ -0,0 +1,159 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace tests\integration\Espo\Stream; + +use Espo\Core\Acl\Table; +use Espo\Core\Exceptions\Forbidden; +use Espo\Core\Record\CreateParams; +use Espo\Core\Record\ServiceContainer; +use Espo\Core\Record\UpdateParams; +use Espo\Core\Select\SearchParams; +use Espo\Entities\Note; +use Espo\Modules\Crm\Entities\KnowledgeBaseArticle; +use Espo\Tools\Stream\RecordService; +use tests\integration\Core\BaseTestCase; + +class AuditTest extends BaseTestCase +{ + public function testAudit1(): void + { + $this->createUser('test1', [ + 'auditPermission' => Table::LEVEL_YES, + 'data' => [ + KnowledgeBaseArticle::ENTITY_TYPE => [ + 'create' => Table::LEVEL_NO, + 'read' => Table::LEVEL_ALL, + ], + ] + ]); + + $this->createUser('test2', [ + 'auditPermission' => Table::LEVEL_NO, + 'data' => [ + KnowledgeBaseArticle::ENTITY_TYPE => [ + 'create' => Table::LEVEL_NO, + 'read' => Table::LEVEL_ALL, + ], + ] + ]); + + $this->createUser('test3', [ + 'auditPermission' => Table::LEVEL_NO, + 'data' => [ + KnowledgeBaseArticle::ENTITY_TYPE => [ + 'create' => Table::LEVEL_NO, + 'read' => Table::LEVEL_NO, + ], + ] + ]); + + $this->getMetadata()->set('entityDefs', KnowledgeBaseArticle::ENTITY_TYPE, [ + 'fields' => [ + 'publishDate' => [ + 'audited' => true, + ], + ], + ]); + $this->getMetadata()->save(); + + $this->reCreateApplication(); + + $service = $this->getContainer() + ->getByClass(ServiceContainer::class) + ->getByClass(KnowledgeBaseArticle::class); + + /** @noinspection PhpUnhandledExceptionInspection */ + $article = $service->create((object) [ + 'name' => 'Test 1', + 'publishDate' => '2025-01-01', + ], CreateParams::create()); + + /** @noinspection PhpUnhandledExceptionInspection */ + $service->update($article->getId(), (object) [ + 'publishDate' => '2025-01-02', + ], UpdateParams::create()); + + $em = $this->getEntityManager(); + + $note = $em->getRDBRepositoryByClass(Note::class) + ->where([ + 'parentId' => $article->getId(), + 'parentType' => $article->getEntityType(), + 'type' => Note::TYPE_UPDATE, + ]) + ->findOne(); + + $this->assertNotNull($note); + $this->assertEquals(['publishDate'], $note->getData()?->fields); + + $searchParams = SearchParams::create(); + + $this->auth('test1'); + $this->reCreateApplication(); + + $service = $this->getInjectableFactory()->create(RecordService::class); + /** @noinspection PhpUnhandledExceptionInspection */ + $recordCollection = $service->findUpdates(KnowledgeBaseArticle::ENTITY_TYPE, $article->getId(), $searchParams); + + $this->assertCount(1, $recordCollection->getCollection()->getValueMapList()); + + $this->auth('test2'); + $this->reCreateApplication(); + + $isThrown = false; + + try { + $service = $this->getInjectableFactory()->create(RecordService::class); + /** @noinspection PhpUnhandledExceptionInspection */ + $service->findUpdates(KnowledgeBaseArticle::ENTITY_TYPE, $article->getId(), $searchParams); + } + catch (Forbidden) { + $isThrown = true; + } + + $this->assertTrue($isThrown); + + $this->auth('test3'); + $this->reCreateApplication(); + + $isThrown = false; + + try { + $service = $this->getInjectableFactory()->create(RecordService::class); + /** @noinspection PhpUnhandledExceptionInspection */ + $service->findUpdates(KnowledgeBaseArticle::ENTITY_TYPE, $article->getId(), $searchParams); + } + catch (Forbidden) { + $isThrown = true; + } + + $this->assertTrue($isThrown); + } +}