feat: Audit Logs for Account User Changes (#7405)

- Audit log for user invitations: https://linear.app/chatwoot/issue/CW-1768/invited-a-user-to-the-account
- Audit log for change role: https://linear.app/chatwoot/issue/CW-1767/name-or-email-changed-the-role-of-the-user-email-to-agent-or-admin
- Audit log for status change: https://linear.app/chatwoot/issue/CW-1766/availability-status-as-events-for-audit-logs


Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
This commit is contained in:
Sojan Jose
2023-06-28 22:42:06 +05:30
committed by GitHub
parent d05c953eef
commit 40830046e8
13 changed files with 384 additions and 46 deletions

View File

@@ -0,0 +1,147 @@
const roleMapping = {
0: 'agent',
1: 'administrator',
};
const availabilityMapping = {
0: 'online',
1: 'offline',
2: 'busy',
};
const translationKeys = {
'automationrule:create': `AUDIT_LOGS.AUTOMATION_RULE.ADD`,
'automationrule:update': `AUDIT_LOGS.AUTOMATION_RULE.EDIT`,
'automationrule:destroy': `AUDIT_LOGS.AUTOMATION_RULE.DELETE`,
'webhook:create': `AUDIT_LOGS.WEBHOOK.ADD`,
'webhook:update': `AUDIT_LOGS.WEBHOOK.EDIT`,
'webhook:destroy': `AUDIT_LOGS.WEBHOOK.DELETE`,
'inbox:create': `AUDIT_LOGS.INBOX.ADD`,
'inbox:update': `AUDIT_LOGS.INBOX.EDIT`,
'inbox:destroy': `AUDIT_LOGS.INBOX.DELETE`,
'user:sign_in': `AUDIT_LOGS.USER_ACTION.SIGN_IN`,
'user:sign_out': `AUDIT_LOGS.USER_ACTION.SIGN_OUT`,
'team:create': `AUDIT_LOGS.TEAM.ADD`,
'team:update': `AUDIT_LOGS.TEAM.EDIT`,
'team:destroy': `AUDIT_LOGS.TEAM.DELETE`,
'macro:create': `AUDIT_LOGS.MACRO.ADD`,
'macro:update': `AUDIT_LOGS.MACRO.EDIT`,
'macro:destroy': `AUDIT_LOGS.MACRO.DELETE`,
'accountuser:create': `AUDIT_LOGS.ACCOUNT_USER.ADD`,
'accountuser:update:self': `AUDIT_LOGS.ACCOUNT_USER.EDIT.SELF`,
'accountuser:update:other': `AUDIT_LOGS.ACCOUNT_USER.EDIT.OTHER`,
};
function extractAttrChange(attrChange) {
if (Array.isArray(attrChange)) {
return attrChange[attrChange.length - 1];
}
return attrChange;
}
export function extractChangedAccountUserValues(auditedChanges) {
let changes = [];
let values = [];
// Check roles
if (auditedChanges.role && auditedChanges.role.length) {
changes.push('role');
values.push(roleMapping[extractAttrChange(auditedChanges.role)]);
}
// Check availability
if (auditedChanges.availability && auditedChanges.availability.length) {
changes.push('availability');
values.push(
availabilityMapping[extractAttrChange(auditedChanges.availability)]
);
}
return { changes, values };
}
function getAgentName(userId, agentList) {
if (userId === null) {
return 'System';
}
const agentName = agentList.find(agent => agent.id === userId)?.name;
// If agent does not exist(removed/deleted), return userId
return agentName || userId;
}
function handleAccountUserCreate(auditLogItem, translationPayload, agentList) {
translationPayload.invitee = getAgentName(
auditLogItem.audited_changes.user_id,
agentList
);
const roleKey = auditLogItem.audited_changes.role;
translationPayload.role = roleMapping[roleKey] || 'unknown'; // 'unknown' as a fallback in case an unrecognized key is provided
return translationPayload;
}
function handleAccountUserUpdate(auditLogItem, translationPayload, agentList) {
if (auditLogItem.user_id !== auditLogItem.auditable.user_id) {
translationPayload.user = getAgentName(
auditLogItem.auditable.user_id,
agentList
);
}
const accountUserChanges = extractChangedAccountUserValues(
auditLogItem.audited_changes
);
if (accountUserChanges) {
translationPayload.attributes = accountUserChanges.changes;
translationPayload.values = accountUserChanges.values;
}
return translationPayload;
}
export function generateTranslationPayload(auditLogItem, agentList) {
let translationPayload = {
agentName: getAgentName(auditLogItem.user_id, agentList),
id: auditLogItem.auditable_id,
};
const auditableType = auditLogItem.auditable_type.toLowerCase();
const action = auditLogItem.action.toLowerCase();
if (auditableType === 'accountuser') {
if (action === 'create') {
translationPayload = handleAccountUserCreate(
auditLogItem,
translationPayload,
agentList
);
}
if (action === 'update') {
translationPayload = handleAccountUserUpdate(
auditLogItem,
translationPayload,
agentList
);
}
}
return translationPayload;
}
export const generateLogActionKey = auditLogItem => {
const auditableType = auditLogItem.auditable_type.toLowerCase();
const action = auditLogItem.action.toLowerCase();
let logActionKey = `${auditableType}:${action}`;
if (auditableType === 'accountuser' && action === 'update') {
logActionKey +=
auditLogItem.user_id === auditLogItem.auditable.user_id
? ':self'
: ':other';
}
return translationKeys[logActionKey] || '';
};

View File

@@ -0,0 +1,146 @@
import {
extractChangedAccountUserValues,
generateTranslationPayload,
generateLogActionKey,
} from '../auditlogHelper'; // import the functions
describe('Helper functions', () => {
const agentList = [
{ id: 1, name: 'Agent 1' },
{ id: 2, name: 'Agent 2' },
{ id: 3, name: 'Agent 3' },
];
describe('extractChangedAccountUserValues', () => {
it('should correctly extract values when role is changed', () => {
const changes = {
role: [0, 1],
};
const {
changes: extractedChanges,
values,
} = extractChangedAccountUserValues(changes);
expect(extractedChanges).toEqual(['role']);
expect(values).toEqual(['administrator']);
});
it('should correctly extract values when availability is changed', () => {
const changes = {
availability: [0, 2],
};
const {
changes: extractedChanges,
values,
} = extractChangedAccountUserValues(changes);
expect(extractedChanges).toEqual(['availability']);
expect(values).toEqual(['busy']);
});
it('should correctly extract values when both are changed', () => {
const changes = {
role: [1, 0],
availability: [1, 2],
};
const {
changes: extractedChanges,
values,
} = extractChangedAccountUserValues(changes);
expect(extractedChanges).toEqual(['role', 'availability']);
expect(values).toEqual(['agent', 'busy']);
});
});
describe('generateTranslationPayload', () => {
it('should handle AccountUser create', () => {
const auditLogItem = {
auditable_type: 'AccountUser',
action: 'create',
user_id: 1,
auditable_id: 123,
audited_changes: {
user_id: 2,
role: 1,
},
};
const payload = generateTranslationPayload(auditLogItem, agentList);
expect(payload).toEqual({
agentName: 'Agent 1',
id: 123,
invitee: 'Agent 2',
role: 'administrator',
});
});
it('should handle AccountUser update', () => {
const auditLogItem = {
auditable_type: 'AccountUser',
action: 'update',
user_id: 1,
auditable_id: 123,
audited_changes: {
user_id: 2,
role: [1, 0],
availability: [0, 2],
},
auditable: {
user_id: 3,
},
};
const payload = generateTranslationPayload(auditLogItem, agentList);
expect(payload).toEqual({
agentName: 'Agent 1',
id: 123,
user: 'Agent 3',
attributes: ['role', 'availability'],
values: ['agent', 'busy'],
});
});
it('should handle generic case like Team create', () => {
const auditLogItem = {
auditable_type: 'Team',
action: 'create',
user_id: 1,
auditable_id: 456,
};
const payload = generateTranslationPayload(auditLogItem, agentList);
expect(payload).toEqual({
agentName: 'Agent 1',
id: 456,
});
});
});
describe('generateLogActionKey', () => {
it('should generate correct action key when user updates self', () => {
const auditLogItem = {
auditable_type: 'AccountUser',
action: 'update',
user_id: 1,
auditable: {
user_id: 1,
},
};
const logActionKey = generateLogActionKey(auditLogItem);
expect(logActionKey).toEqual('AUDIT_LOGS.ACCOUNT_USER.EDIT.SELF');
});
it('should generate correct action key when user updates other agent', () => {
const auditLogItem = {
auditable_type: 'AccountUser',
action: 'update',
user_id: 1,
auditable: {
user_id: 2,
},
};
const logActionKey = generateLogActionKey(auditLogItem);
expect(logActionKey).toEqual('AUDIT_LOGS.ACCOUNT_USER.EDIT.OTHER');
});
});
});

View File

@@ -19,7 +19,6 @@
"SUCCESS_MESSAGE": "AuditLogs retrieved successfully",
"ERROR_MESSAGE": "تعذر الاتصال بالخادم، الرجاء المحاولة مرة أخرى لاحقاً"
},
"DEFAULT_USER": "System",
"AUTOMATION_RULE": {
"ADD": "%{agentName} created a new automation rule (#%{id})",
"EDIT": "%{agentName} updated an automation rule (#%{id})",

View File

@@ -25,6 +25,13 @@
"EDIT": "%{agentName} updated an automation rule (#%{id})",
"DELETE": "%{agentName} deleted an automation rule (#%{id})"
},
"ACCOUNT_USER": {
"ADD": "%{agentName} invited %{invitee} to the account as an %{role}",
"EDIT": {
"SELF": "%{agentName} changed their %{attributes} to %{values}",
"OTHER": "%{agentName} changed %{attributes} of %{user} to %{values}"
}
},
"INBOX": {
"ADD": "%{agentName} created a new inbox (#%{id})",
"EDIT": "%{agentName} updated an inbox (#%{id})",

View File

@@ -66,6 +66,10 @@ import { mapGetters } from 'vuex';
import TableFooter from 'dashboard/components/widgets/TableFooter';
import timeMixin from 'dashboard/mixins/time';
import alertMixin from 'shared/mixins/alertMixin';
import {
generateTranslationPayload,
generateLogActionKey,
} from 'dashboard/helper/auditlogHelper';
export default {
components: {
@@ -107,48 +111,14 @@ export default {
this.showAlert(errorMessage);
});
},
getAgentName(email) {
if (email === null) {
return this.$t('AUDIT_LOGS.DEFAULT_USER');
}
const agentName = this.agentList.find(agent => agent.email === email)
?.name;
// If agent does not exist(removed/deleted), return email from audit log
return agentName || email;
},
generateLogText(auditLogItem) {
const agentName = this.getAgentName(auditLogItem.username);
const auditableType = auditLogItem.auditable_type.toLowerCase();
const action = auditLogItem.action.toLowerCase();
const auditId = auditLogItem.auditable_id;
const logActionKey = `${auditableType}:${action}`;
const translationPayload = generateTranslationPayload(
auditLogItem,
this.agentList
);
const translationKey = generateLogActionKey(auditLogItem);
const translationPayload = {
agentName,
id: auditId,
};
const translationKeys = {
'automationrule:create': `AUDIT_LOGS.AUTOMATION_RULE.ADD`,
'automationrule:update': `AUDIT_LOGS.AUTOMATION_RULE.EDIT`,
'automationrule:destroy': `AUDIT_LOGS.AUTOMATION_RULE.DELETE`,
'webhook:create': `AUDIT_LOGS.WEBHOOK.ADD`,
'webhook:update': `AUDIT_LOGS.WEBHOOK.EDIT`,
'webhook:destroy': `AUDIT_LOGS.WEBHOOK.DELETE`,
'inbox:create': `AUDIT_LOGS.INBOX.ADD`,
'inbox:update': `AUDIT_LOGS.INBOX.EDIT`,
'inbox:destroy': `AUDIT_LOGS.INBOX.DELETE`,
'user:sign_in': `AUDIT_LOGS.USER_ACTION.SIGN_IN`,
'user:sign_out': `AUDIT_LOGS.USER_ACTION.SIGN_OUT`,
'team:create': `AUDIT_LOGS.TEAM.ADD`,
'team:update': `AUDIT_LOGS.TEAM.EDIT`,
'team:destroy': `AUDIT_LOGS.TEAM.DELETE`,
'macro:create': `AUDIT_LOGS.MACRO.ADD`,
'macro:update': `AUDIT_LOGS.MACRO.EDIT`,
'macro:destroy': `AUDIT_LOGS.MACRO.DELETE`,
};
return this.$t(translationKeys[logActionKey] || '', translationPayload);
return this.$t(translationKey, translationPayload);
},
onPageChange(page) {
window.history.pushState({}, null, `${this.$route.path}?page=${page}`);