audit log

This commit is contained in:
Yuri Kuznetsov
2024-02-08 15:53:01 +02:00
parent 2681acebc6
commit 246ece8e3d
13 changed files with 504 additions and 7 deletions
+38
View File
@@ -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;
}
}
@@ -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...",
@@ -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",
@@ -24,6 +24,11 @@
{"name": "massUpdatePermission"},
{"name": "followerManagementPermission"},
{"name": "messagePermission"}
],
[
{"name": "auditPermission"},
false,
false
]
]
}
@@ -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,
@@ -0,0 +1,11 @@
{
"detailActionList": [
{
"name": "viewAuditLog",
"label": "View Audit Log",
"actionFunction": "show",
"checkVisibilityFunction": "isAvailable",
"handler": "handlers/record/view-audit-log"
}
]
}
@@ -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"
},
+10
View File
@@ -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",
@@ -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<string, mixed> $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);
}
}
}
@@ -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<Note>
* @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<Note>
* @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<string|int, mixed> $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];
}
}
@@ -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 <https://www.gnu.org/licenses/>.
*
* 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;
@@ -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 <https://www.gnu.org/licenses/>.
*
* 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 = '<div class="record list-container">{{{record}}}</div>'
backdrop = true
/**
* @param {{model: import('model').default}} options
*/
constructor(options) {
super(options);
}
setup() {
const name = this.model.get('name') || this.model.id;
this.$header = $('<span>')
.append(
$('<span>').text(name),
' <span class="chevron-right"></span> ',
$('<span>').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;
+159
View File
@@ -0,0 +1,159 @@
<?php
/************************************************************************
* 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 <https://www.gnu.org/licenses/>.
*
* 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);
}
}