lp-audit
Version:
Adds comprehensive audit trail functionality to Loopback by keeping track of who created/modified/deleted data and when they did it, and adds a revisions model compatible with Sofa/Revisionable for PHP (https://github.com/jarektkaczyk/revisionable)
636 lines (563 loc) • 24.3 kB
JavaScript
;
import _debug from './debug';
const assert = require('assert');
const debug = _debug();
const warn = (options, ...rest) => {
if (!options.silenceWarnings) {
console.warn(...rest);
}
};
Object.compare = function (obj1, obj2) {
//Loop through properties in object 1
for (var p in obj1) {
//Check property exists on both objects
if (obj1.hasOwnProperty(p) !== obj2.hasOwnProperty(p)) return false;
if (obj1[p] === null || obj2[p] === null) {
return obj1[p] === obj2[p];
}
switch (typeof (obj1[p])) {
//Deep compare objects
case 'object':
if (typeof (obj2[p]) !== 'object') return false;
if (!Object.compare(obj1[p], obj2[p])) return false;
break;
//Compare function code
case 'function':
if (typeof (obj2[p]) === 'undefined' || (p !== 'compare' && obj1[p].toString() !== obj2[p].toString())) return false;
break;
//Compare values
default:
if (obj1[p] !== obj2[p]) return false;
}
}
//Check object 2 for any extra properties
for (var p in obj2) {
if (typeof (obj1[p]) === 'undefined') return false;
}
return true;
};
export default (Model, bootOptions = {}) => {
debug('Auditz mixin for Model %s', Model.modelName);
let app;
const options = Object.assign({
createdAt: 'createdAt',
updatedAt: 'updatedAt',
deletedAt: 'deletedAt',
createdBy: 'createdBy',
updatedBy: 'updatedBy',
deletedBy: 'deletedBy',
softDelete: true,
unknownUser: '0',
scrub: false,
required: true,
validateUpsert: false, // default to turning validation off
silenceWarnings: false,
revisions: {
name: 'revisions',
idType: 'Number',
dataSource: 'db',
autoUpdate: true,
remoteContextData: [],
},
}, bootOptions);
options.revisionsModelName = (typeof options.revisions === 'object' && options.revisions.name) ? options.revisions.name : null;
const properties = Model.definition.properties;
const idName = Model.dataSource.idName(Model.modelName);
let scrubbed = {};
if (options.softDelete) {
if (options.scrub !== false) {
let propertiesToScrub = options.scrub;
if (!Array.isArray(propertiesToScrub)) {
propertiesToScrub = Object.keys(properties)
.filter(prop => !properties[prop][idName] && prop !== options.deletedAt && prop !== options.deletedBy);
}
scrubbed = propertiesToScrub.reduce((obj, prop) => ({...obj, [prop]: null}), {});
}
}
if (!options.validateUpsert && Model.settings.validateUpsert) {
Model.settings.validateUpsert = false;
warn(options, `${Model.pluralModelName} settings.validateUpsert was overridden to false`);
}
if (Model.settings.validateUpsert && options.required) {
warn(options, `Upserts for ${Model.pluralModelName} will fail when
validation is turned on and time stamps are required`);
}
Model.settings.validateUpsert = options.validateUpsert;
if (options.createdAt !== false) {
if (typeof (properties[options.createdAt]) === 'undefined') {
Model.defineProperty(options.createdAt, {type: Date, required: options.required, defaultFn: 'now'});
}
}
if (options.updatedAt !== false) {
if (typeof (properties[options.updatedAt]) === 'undefined') {
Model.defineProperty(options.updatedAt, {type: Date, required: options.required});
}
}
if (options.createdBy !== false) {
if (typeof (properties[options.createdBy]) === 'undefined') {
Model.defineProperty(options.createdBy, {type: String, required: false, mongodb: {dataType: 'ObjectID'}});
}
}
if (options.updatedBy !== false) {
if (typeof (properties[options.updatedBy]) === 'undefined') {
Model.defineProperty(options.updatedBy, {type: String, required: false, mongodb: {dataType: 'ObjectID'}});
}
}
if (options.softDelete) {
if (typeof (properties[options.deletedAt]) === 'undefined') {
Model.defineProperty(options.deletedAt, {type: Date, required: false, 'default': null});
}
if (typeof (properties[options.deletedBy]) === 'undefined') {
Model.defineProperty(options.deletedBy, {type: String, required: false, mongodb: {dataType: 'ObjectID'}});
}
}
Model.observe('after save', (ctx, next) => {
if (!options.revisions) {
return next();
}
debug('ctx.options', ctx.options);
// determine the currently logged in user. Default to options.unknownUser
let currentUser = options.unknownUser;
if (ctx.options.accessToken) {
currentUser = ctx.options.accessToken.userId;
}
Model.getApp((err, a) => {
if (err) {
return next(err);
}
app = a;
let ipForwarded = '';
let ip = '127.0.0.1';
if (ctx.options.ip || ctx.options.ipForwarded) {
ipForwarded = ctx.options.ipForwarded || '';
ip = ctx.options.ip;
}
let groups = options.revisions.groups;
let saveGroups = function (err) {
if (err) {
next(err);
return;
}
if (groups && Array.isArray(groups)) {
let count = 0;
if (!(ctx.options && ctx.options.delete)) {
groups.forEach(function (group) {
createOrUpdateRevision(ctx, group, currentUser, ipForwarded, ip, function () {
count += 1;
if (count === groups.length) {
next();
}
});
});
return;
}
}
next();
};
// If it's a new instance, set the createdBy to currentUser
if (ctx.isNewInstance) {
let data = {
action: 'create',
table_name: Model.modelName,
row_id: ctx.instance.id,
old: null,
new: ctx.instance,
user: currentUser,
ip: ip,
ip_forwarded: ipForwarded,
};
//this is to allow adding data from remoting context to the revisions model
if (options.revisions.remoteContextData && options.revisions.remoteContextData.length > 0) {
options.revisions.remoteContextData.forEach((property) => {
if (ctx.options[property]) {
data[property] = ctx.options[property];
}
});
}
app.models[options.revisionsModelName].create(data, saveGroups);
} else {
if (ctx.options && ctx.options.delete) {
if (ctx.options.oldInstance) {
app.models[options.revisionsModelName].create({
action: 'delete',
table_name: Model.modelName,
row_id: ctx.options.oldInstance.id,
old: ctx.options.oldInstance,
new: null,
user: currentUser,
ip: ip,
ip_forwarded: ipForwarded,
}, saveGroups);
} else if (ctx.options.oldInstances) {
const entries = ctx.options.oldInstances.map(inst => {
return {
action: 'delete',
table_name: Model.modelName,
row_id: inst.id,
old: inst,
new: null,
user: currentUser,
ip: ip,
ip_forwarded: ipForwarded,
};
});
app.models[options.revisionsModelName].create(entries, saveGroups);
} else {
debug('Cannot register delete without old instance! Options: %j', ctx.options);
return saveGroups();
}
} else {
if (ctx.options.oldInstance && ctx.instance) {
const inst = ctx.instance;
app.models[options.revisionsModelName].create({
action: 'update',
table_name: Model.modelName,
row_id: inst.id,
old: ctx.options.oldInstance,
new: inst,
user: currentUser,
ip: ip,
ip_forwarded: ipForwarded,
}, saveGroups);
} else if (ctx.options.oldInstances) {
const updatedIds = ctx.options.oldInstances.map(inst => {
return inst.id;
});
let newInst = {};
const query = {where: {[idName]: {inq: updatedIds}}};
app.models[Model.modelName].find(query, (error, newInstances) => {
if (error) {
return next(error);
}
newInstances.forEach(inst => {
newInst[inst[idName]] = inst;
});
const entries = ctx.options.oldInstances.map(inst => {
return {
action: 'update',
table_name: Model.modelName,
row_id: inst.id,
old: inst,
new: newInst[inst.id],
user: currentUser,
ip: ip,
ip_forwarded: ipForwarded,
};
});
app.models[options.revisionsModelName].create(entries, saveGroups);
});
} else {
debug('Cannot register update without old and new instance. Options: %j', ctx.options);
debug('instance: %j', ctx.instance);
debug('data: %j', ctx.data);
return saveGroups();
}
}
}
});
});
function cloneKey(key, from, to) {
let parts = key.split('.');
let toObject = to;
let fromObject = from;
parts.forEach(function (key, index) {
if (index === parts.length - 1) {
toObject[key] = fromObject && fromObject[key];
} else {
if (!toObject[key]) {
toObject[key] = {};
}
}
fromObject = fromObject && fromObject[key];
toObject = toObject[key];
});
}
function createOrUpdateRevision(ctx, group, currentUser, ipForwarded, ip, cb) {
let data = {};
group.properties.forEach(function (key) {
cloneKey(key, ctx.instance, data);
});
debug(data);
let rec = {
table_name: Model.modelName,
row_id: ctx.instance.id,
new: data,
user: currentUser,
ip: ip,
ip_forwarded: ipForwarded,
};
if (ctx.isNewInstance) {
rec.action = 'create';
rec.old = null;
app.models[group.name].create(rec, cb);
} else {
rec.action = 'update';
rec.old = ctx.options.oldInstance || null;
if (rec.old) {
let old = {};
//make sure the object is pure
group.properties.forEach(function (key) {
cloneKey(key, rec.old, old);
});
rec.old = old;
}
//get away from undefined properties so compare can work
let recNew = JSON.parse(JSON.stringify(rec.new));
let recOld = rec.old && JSON.parse(JSON.stringify(rec.old));
if (rec.old && Object.compare(recNew, recOld)) {
console.log('equal ' + group.name);
return cb();
}
app.models[group.name].create(rec, cb);
}
}
function getOldInstance(ctx, cb) {
if (options.revisions) {
if (typeof ctx.isNewInstance === 'undefined' || !ctx.isNewInstance) {
let id = ctx.instance ? ctx.instance.id : null;
if (!id) {
id = ctx.data ? ctx.data.id : null;
}
if (!id && ctx.where) {
id = ctx.where.id;
}
if (!id && ctx.options.remoteCtx) {
id = ctx.options.remoteCtx.req && ctx.options.remoteCtx.req.args ?
ctx.options.remoteCtx.req.args.id : null;
}
if (id) {
Model.findById(id, {deleted: true}, (err, oldInstance) => {
if (err) {
cb(err);
} else {
cb(null, oldInstance);
}
});
} else {
const query = {where: ctx.where} || {};
Model.find(query, (err, oldInstances) => {
if (err) {
cb(err);
} else {
if (oldInstances.length > 1) {
return cb(null, oldInstances);
} else if (oldInstances.length === 0) {
return cb();
}
cb(null, oldInstances[0]);
}
});
}
} else {
cb();
}
} else {
cb();
}
}
Model.observe('before save', (ctx, next) => {
const softDelete = ctx.options.delete;
getOldInstance(ctx, (err, result) => {
if (err) {
console.error(err);
return next(err);
}
if (Array.isArray(result)) {
ctx.options.oldInstances = result;
} else {
ctx.options.oldInstance = result;
}
// determine the currently logged in user. Default to options.unknownUser
let currentUser = options.unknownUser;
if (ctx.options.accessToken) {
currentUser = ctx.options.accessToken.userId;
}
// If it's a new instance, set the createdBy to currentUser
if (ctx.isNewInstance) {
debug('Setting %s.%s to %s', ctx.Model.modelName, options.createdBy, currentUser);
ctx.instance[options.createdBy] = currentUser;
if (options.softDelete) {
ctx.instance[options.deletedAt] = null;
}
} else {
// if the createdBy and createdAt are sent along in the data to save, remove the keys
// as we don't want to let the user overwrite it
if (ctx.instance) {
delete ctx.instance[options.createdBy];
delete ctx.instance[options.createdAt];
} else {
delete ctx.data[options.createdBy];
delete ctx.data[options.createdAt];
}
}
if (ctx.options && ctx.options.skipUpdatedAt) {
return next();
}
let keyAt = options.updatedAt;
let keyBy = options.updatedBy;
if (options.softDelete) {
// Since soft deletes replace the actual delete by an update, we set the option
// 'delete' in the overridden delete functions that perform updates.
// We now have to determine if we need to set updatedAt/updatedBy or
// deletedAt/deletedBy
if (softDelete) {
keyAt = options.deletedAt;
keyBy = options.deletedBy;
}
}
let obj;
if (ctx.instance) {
obj = ctx.instance;
} else {
obj = ctx.data;
}
if (keyAt !== false) {
obj[keyAt] = new Date();
}
if (keyBy !== false) {
obj[keyBy] = currentUser;
}
return next();
});
});
if (options.softDelete) {
Model.destroyAll = function softDestroyAll(where, opt, cb) {
let query = where || {};
let callback = (cb === undefined && typeof opt === 'function') ? opt : cb;
let newOpt = {delete: true};
if (typeof opt === 'object') {
newOpt = {...opt, ...newOpt};
}
if (typeof where === 'function') {
callback = where;
query = {};
}
return Model.updateAll(query, {...scrubbed}, newOpt)
.then(result => (typeof callback === 'function') ? callback(null, result) : result)
.catch(error => (typeof callback === 'function') ? callback(error) : Promise.reject(error));
};
Model.remove = Model.destroyAll;
Model.deleteAll = Model.destroyAll;
Model.destroyById = function softDestroyById(id, opt, cb) {
const callback = (cb === undefined && typeof opt === 'function') ? opt : cb;
let newOpt = {delete: true};
if (typeof opt === 'object') {
newOpt = {...opt, ...newOpt};
}
return Model.updateAll({[idName]: id}, {...scrubbed}, newOpt)
.then(result => (typeof callback === 'function') ? callback(null, result) : result)
.catch(error => (typeof callback === 'function') ? callback(error) : Promise.reject(error));
};
Model.removeById = Model.destroyById;
Model.deleteById = Model.destroyById;
Model.prototype.destroy = function softDestroy(opt, cb) {
const callback = (cb === undefined && typeof opt === 'function') ? opt : cb;
return this.updateAttributes({...scrubbed}, {delete: true})
.then(result => (typeof cb === 'function') ? callback(null, result) : result)
.catch(error => (typeof cb === 'function') ? callback(error) : Promise.reject(error));
};
Model.prototype.remove = Model.prototype.destroy;
Model.prototype.delete = Model.prototype.destroy;
// Emulate default scope but with more flexibility.
const queryNonDeleted = {[options.deletedAt]: null};
const _findOrCreate = Model.findOrCreate;
Model.findOrCreate = function findOrCreateDeleted(query = {}, ...rest) {
if (!query.deleted) {
if (!query.where || Object.keys(query.where).length === 0) {
query.where = queryNonDeleted;
} else {
query.where = {and: [query.where, queryNonDeleted]};
}
}
return _findOrCreate.call(Model, query, ...rest);
};
const _find = Model.find;
Model.find = function findDeleted(query = {}, ...rest) {
if (!query.deleted) {
if (!query.where || Object.keys(query.where).length === 0) {
query.where = queryNonDeleted;
} else {
query.where = {and: [query.where, queryNonDeleted]};
}
}
return _find.call(Model, query, ...rest);
};
const _count = Model.count;
Model.count = function countDeleted(where = {}, ...rest) {
// Because count only receives a 'where', there's nowhere to ask for the deleted entities.
let whereNotDeleted;
if (!where || Object.keys(where).length === 0) {
whereNotDeleted = queryNonDeleted;
} else {
whereNotDeleted = {and: [where, queryNonDeleted]};
}
return _count.call(Model, whereNotDeleted, ...rest);
};
const _update = Model.update;
Model.update = Model.updateAll = function updateDeleted(where = {}, ...rest) {
// Because update/updateAll only receives a 'where', there's nowhere to ask for the deleted entities.
let whereNotDeleted;
if (!where || Object.keys(where).length === 0) {
whereNotDeleted = queryNonDeleted;
} else {
whereNotDeleted = {and: [where, queryNonDeleted]};
}
return _update.call(Model, whereNotDeleted, ...rest);
};
}
function _setupRevisionsModel(app, opts) {
const autoUpdate = (opts.revisions === true || (typeof opts.revisions === 'object' && opts.revisions.autoUpdate));
const dsName = (typeof opts.revisions === 'object' && opts.revisions.dataSource) ?
opts.revisions.dataSource : 'db';
const rowIdType = (typeof opts.revisions === 'object' && opts.revisions.idType) ?
opts.revisions.idType : 'Number';
if (options.revisionsModelName) {
_createModel(opts, dsName, autoUpdate, rowIdType, {name: options.revisionsModelName});
}
if (opts.revisions && typeof opts.revisions === 'object' &&
opts.revisions.groups && opts.revisions.groups.length) {
opts.revisions.groups.forEach(function (group) {
if (!app.models[group.name]) {
_createModel(opts, dsName, autoUpdate, rowIdType, group);
}
});
}
}
function _createModel(opts, dsName, autoUpdate, rowIdType, group) {
const revisionsDef = require('./models/revision.json');
let settings = {};
for (let s in revisionsDef) {
if (s !== 'name' && s !== 'properties') {
settings[s] = revisionsDef[s];
}
}
settings['plural'] = group.plural;
revisionsDef.properties.row_id.type = rowIdType;
const revisionsModel = app.dataSources[dsName].createModel(
group.name,
revisionsDef.properties,
settings
);
const revisions = require('./models/revision')(revisionsModel, opts);
app.model(revisions);
if (autoUpdate) {
// create or update the revisions table
app.dataSources[dsName].autoupdate([group.name], (error) => {
if (error) {
console.error(error);
}
});
}
}
if (options.revisions) {
Model.getApp((err, a) => {
if (err) {
return console.error(err);
}
app = a;
if (!app.models[options.revisionsModelName]) {
_setupRevisionsModel(app, options);
}
});
}
};