import { validateArgs, validateString, validateStringArray, validateObject, validateDate, } from '../utils/validating'; import { type Obj, cleanStructure, pretty, simpleEquals, } from '../utils/helpers'; // Actual value as can be defined by a user in a product. type Value = string | boolean | number | null; // values inside Array or an Array of Objects. type ArrayValue = Value[] | {[key: string]: Value}[]; // Wrapper for all kinds of values accepted. Object is not accepted because // we want to flatten things as much as possible. type Data = Value | ArrayValue; // When a user changes a value somewhere, it should be represented with 'old' and 'new'. export type EditedValue = { old?: Data, new?: Data, }; // 'CreatedItem' is {[key: string]: Data} but keys can't be equal to any // key of 'EditedValue' because 'old' and 'new' are reserved for only 'EditedValue'. type CreatedItem = {[key: string]: Data} & Partial>; // Represents one item inside a group of items that belong to an Object in a product. type ChangedItem = { [key: string]: EditedValue, }; // Represents a group of items that belong to an Object in a product when audit operation is an 'edit'. type ChangedItems = { [key: string]: ChangedItem | CreatedItem | null, // 'null' represents that item has been deleted. }; // Represents a group of items that belong to an Object in a product when audit operation is an 'create'. type CreatedItems = { [key: string]: CreatedItem, }; // Common part to any 'Audit'. Field name and types to be used in FlashAudit database. interface AuditBase { client: string, // client id or name. product: string, // product name. "flashman" is an example. user: string, // user id in string format. version: 1, // format version. object: string, // the name of the object changed. // 'searchable' is what the user understands as the object's identification. // This is an array, because some product Objects can be identified by more than one string. searchable: string[], } // Part of 'Audit' that is unique to 'Audit'. type AuditOnlyStruct = { date: Date, } // Part of 'AuditMessage' that is unique to 'Audit'. type AuditMessageOnlyStruct = { // because this will become a JSON string, representing date as a number is // smaller than ISOString an it's faster to parse back to date. date: number, } // enum with the 'operation' names. Can be used as a type. export type Operations = 'edit' | 'create' | 'delete' | 'trigger'; // map to use when checking strings to be a valid operation name. const operations = { edit: true, create: true, delete: true, trigger: true, } as Record; // when user edits a value in a product, 'operation' should be "edit". type OperationEdit = { operation: 'edit', values: EditValues, }; // 'values' structure for 'operation' "edit". export type EditValues = {[key: string]: EditedValue | ChangedItems}; // when user creates an object in a product, 'operation' should be "create". type OperationCreate = { operation: 'create', values: CreateValues, }; // 'values' structure for 'operation' "create". export type CreateValues = {[key: string]: Data | CreatedItems}; // when user deletes an object in a product, 'operation' should be "delete". export type OperationDelete = { operation: 'delete', values?: DeleteValues, }; // 'values' structure for 'operation' "delete". export type DeleteValues = {[key: string]: Value | Value[]}; // when user issues an event in a product, 'operation' should be "trigger". type OperationTrigger = { operation: 'trigger', values: TriggerValues, }; // 'values' structure for 'operation' "trigger". export type TriggerValues = { cmd: string, [key: string]: Data, }; // Joining audit operations into one type. type AuditCommon = AuditBase & OperationEdit | AuditBase & OperationCreate | AuditBase & OperationDelete | AuditBase & OperationTrigger; // Audit structure inside FlashAudit database. export type Audit = AuditCommon & AuditOnlyStruct; // To be used only in clients that will send audit registers to FlashAudit. export type AuditMessage = AuditCommon & AuditMessageOnlyStruct; /* A product object are registers of things that don't depend on other registers in order to exist. An item is a structure that belongs to a product object and other identical structures could also belong to the same product object. */ /* examples for 'values': 1) When Audit.operation === 'trigger'. values: { cmd: 'speedtest', targets: ['flashman', 'google'] } 2) When Audit.operation === 'create'. values: { id: "aa:bb:cc:dd:ee:ff", date: 1234567896766, lan_devices: { itemId1: { // added item with its starting fields and values. subfield1: 'abc', subfield2: 123, }, }, } 3) When Audit.operation === 'delete'. values: undefined || { failed: true, } 4) When Audit.operation === 'edit'. values: { stringField: {old: 'before', new: 'after'}, numericField: {old: 10, new: 20}, booleanField: {old: false, new: true}, nullField: {old: null, new: 10}, onlyOld: {old: 10}, onlyNew: {new: 10}, items: { // group of items that belong to searchable. itemId1: { // added item with its starting fields and values. subfield1: "abc", itemfield2: [123, 456, 789], itemfield3: [{a: 10, b: 20}, {a: 11, b: 21}, {a: 12, b: 22}], }, itemId2: { // changed item. onlyOld: {old: 10}, onlyNew: {new: 10}, itemfield2: {old: false, new: true}, itemfield3: { old: [{a: 10, b: 20}, {a: 11, b: 21}, {a: 12, b: 22}], new: [{a: 10, b: 20}, {a: 12, b: 22}], }, }, itemId3: null, // deleted item. }, } */ // Returns an Object structure like 'AuditMessage' but without the 'values' attribute. const newAuditMessageCommon = function( client: string, product: string, user: string, object: string, searchable: string[], operation: Operations, ): Partial { return { client, product, user, date: Date.now(), version: 1 as const, object, searchable, operation, } as Partial; }; // returns undefined if given 'operation' string is valid, otherwise returns error string. const checkOperation = function(operation: Operations): string | undefined { if (!operations[operation]) return `operation' has unrecognized value '${operation}'.`; }; // Receives an 'AuditMessage' and returns an 'Audit' from it, stripped from any dangling attributes. // Expects 'AuditMessage' to be already validated. export const convertMessageToAudit = function(message: AuditMessage): Audit { let obj: any = message; obj.date = new Date(obj.date); let generic = mockMessage; // using a mocked message as a reference of a good message. let keysLength = mockMessageAmountOfKeys; if (obj.operation === 'delete' && message.values === undefined) keysLength -= 1; if (Object.keys(obj).length > keysLength) { // if there are dangling keys. const tmp: any = {}; // building a new object. // using a mocked AuditMessage as a reference of a good AuditMessage. for (const key in generic) { const v = obj[key]; if (v !== undefined) tmp[key] = v; // filling object with all known keys. } obj = tmp; } return obj as Audit; }; // receives an object and checks if it has the correct amount of keys. If it does not, // return a string telling which keys as missing or are extra. const checkAmountOfkeys = function (m: Record): string | undefined { const messageKeys = Object.keys(m).length; let genericMessage = mockMessage as Record; let expectedKeys = mockMessageAmountOfKeys; if (m.operation === 'delete' && m.values === undefined) expectedKeys -= 1; if (messageKeys !== expectedKeys) { let msg = 'Missing'; const keys = []; let hasLessKeys = m; let hasMoreKeys = genericMessage; if (messageKeys > expectedKeys) { msg = 'Extra'; hasLessKeys = genericMessage; hasMoreKeys = m; } for (const k in hasMoreKeys) { if (hasLessKeys[k] === undefined) keys.push(k); } msg += ` keys are: ["${keys.join('", "')}"].` return `AuditMessage has ${messageKeys} attributes but should have `+ `${expectedKeys} attributes. `+msg; } } // Meant to be called in TypeSript projects. // When using JavaScript environment, refer to 'buildMessageForJS()'. // Instantiates a new Object in 'AuditMessage' format. Returns a tuple where first element is // the created 'AuditMessage' object and the second element is the message of any found error. export function buildMessage ( client: string, product: string, user: string, object: string, searchable: string[], operation: 'edit', values: EditValues, ): [AuditMessage | undefined, string | undefined]; export function buildMessage ( client: string, product: string, user: string, object: string, searchable: string[], operation: 'create', values: CreateValues, ): [AuditMessage | undefined, string | undefined]; export function buildMessage ( client: string, product: string, user: string, object: string, searchable: string[], operation: 'delete', values?: DeleteValues, ): [AuditMessage | undefined, string | undefined]; export function buildMessage ( client: string, product: string, user: string, object: string, searchable: string[], operation: 'trigger', values: TriggerValues, ): [AuditMessage | undefined, string | undefined]; export function buildMessage ( client: string, product: string, user: string, object: string, searchable: string[], operation: Operations, values?: EditValues | CreateValues | TriggerValues | DeleteValues, ): [AuditMessage | undefined, string | undefined] { // getting an Object with the structure of 'AuditMessage' but without 'values' attribute. const m = newAuditMessageCommon(client, product, user, object, searchable, operation); // delete operations don't always need values. if (operation !== 'delete' || values !== undefined) m.values = values; return [m as AuditMessage, undefined]; }; // Meant to be called in JavaScript projects. // When using TypeSript environment, refer to 'buildMessage()'. // Validates arguments and instantiates a new object in 'AuditMessage' format. Returns a tuple // where first element is the created 'AuditMessage' object and the second element is the // message of any found error. If both elements are undefined, it means the AuditMessage was // actually empty and can be skipped, instead of being sent to FlashAudit. export const buildMessageForJS = function( client: any, product: any, user: any, object: any, searchable: any, operation: any, values?: any, ): [AuditMessage | undefined, string | undefined] { let errmsg = validateMessageCommon(client, product, user, object, searchable, operation); if (errmsg) return [undefined, `Argument '`+errmsg]; // delete operations could have undefined 'values' so we should let 'validateValues()' function // validate it when it's not. But if it is any other operation, we clean the 'values' argument. // If values was a completely empty structure, we return both AuditMessage and error message as // undefined, so a user of this function can ignore this AuditMessage instead of sending it empty. if (operation !== 'delete' || values !== undefined) { values = cleanStructure(values); if (values === undefined) return [undefined, undefined]; } errmsg = validateValues(operation, values); if (errmsg) return [undefined, `Argument `+errmsg]; return buildMessage(client, product, user, object, searchable, operation, values); }; // creating a mock AuditMessage to get the keys out of it. const mockMessage = buildMessage('a', 'b', '0', 'd', ['e'], 'create', {a: 1})[0]; // getting amount of keys in a well formatted message. const mockMessageAmountOfKeys = Object.keys(mockMessage as any).length; // // creating a mock delete operation AuditMessage to get the keys out of it. // const mockDeleteMessage = buildMessage('a', 'b', '0', 'd', ['e'], 'delete')[0]; // // getting amount of keys in a well formatted delete operation message. // const mockDeleteMessageAmountOfKeys = Object.keys(mockDeleteMessage as any).length; // Validates 'AuditMessage' structure, returning a string message if any error was found, or else // returns undefined. // Should be called for every 'Audit' if project is not using TypeScript. // TypeScript projects can leverage from type assertion during transpile time. // Could also be used in TypeScript projects if type assertion error messages are too complicated. export const validateMessage = function (m: any): string | undefined { // expects to receive an AudtiMessage and it should be an Object. if (m === undefined || m === null || m.constructor !== Object) { return `AuditMessage should be an Object.`; } // so far, we only have one version. if (m.version !== 1) return `AuditMessage 'version' should be Number 1.`; // if the following fields are missing or invalid, this validation will return an error message. let errmsg = validateMessageCommon(m.client, m.product, m.user, m.object, m.searchable, m.operation); if (errmsg) return `AuditMessage '`+errmsg; errmsg = validateDate(m.date); if (errmsg) return `AuditMessage '`+errmsg; // after 'date' is validated, it should be converted to Number if it's a Date object // because an 'AuditMessage' has dates in numeric format. if (m.date.constructor === Date) m.date = m.date.valueOf(); else if (m.date.constructor !== Number) return `AuditMessage 'date' should be a Number.`; // validate 'values' field after removing empty structures from it. errmsg = validateValues(m.operation, cleanStructure(m.values)); if (errmsg) return `AuditMessage `+errmsg; // if any extra keys, this check will return an error message. errmsg = checkAmountOfkeys(m); if (errmsg) return errmsg; }; // Helper function for JavaScript runtime Type validation. // Returns string with error message if any field in base 'Audit' is invalid, // otherwise returns undefined. const validateMessageCommon = function( client: any, product: any, user: any, object: any, searchable: any, operation: any, ): string | undefined { let errmsg = validateArgs([ {value: client, name: 'client', func: validateString}, {value: user, name: 'user', func: validateString}, {value: searchable, name: 'searchable', func: validateStringArray}, {value: object, name: 'object', func: validateString}, {value: product, name: 'product', func: validateString}, {value: operation, name: 'operation', func: validateString}, ]); if (errmsg) return errmsg; }; // given an 'operation' name, executes the correct validation for the 'values' attribute. const validateValues = function(operation: any, values?: any): string | undefined { switch (operation) { case 'edit': return validateEditValues(values); case 'create': return validateCreateValues(values); case 'delete': return validateDeleteValues(values); case 'trigger': return validateTriggerValues(values); default: return `operation' has an unrecognized value.`; } }; // Helper function for JavaScript runtime Type validation. const validateDeleteValues = function(values?: DeleteValues): string | undefined { if (values === undefined) return undefined; let errmsg = isValuesValidObject(values); if (errmsg) return `'values`+errmsg; for (let fieldname in values) { const field = values[fieldname]; const fieldType = type(field); switch (fieldType) { case null: case String: case Number: case Boolean: break; case Array: for (let i = 0; i < (field as Value[]).length; i++) { const v = (field as Value[])[i]; const errmsg = isValue(v); if (errmsg) return `'values.${fieldname}[${i}]`+errmsg; } break; default: return `'values.${fieldname}' is type ${pretty(fieldType)} but should be String, `+ `Number, Boolean, null or Array when Audit 'operation' is "delete".`; } } return undefined; }; // Helper function for JavaScript runtime Type validation. const validateTriggerValues = function(values: TriggerValues): string | undefined { let errmsg = isValuesValidObject(values); if (errmsg) return `'values`+errmsg; const cmd = values.cmd; const cmdType = type(cmd); if (cmdType === undefined || cmdType === null || cmdType !== String) { return `'values.cmd' is type ${pretty(cmdType)} but should be a String when Audit `+ `'operation' is "trigger".`; } if (cmd.length === 0) return `'values.cmd' should not be empty.`; for (let fieldname in values) { if (fieldname === 'cmd') continue; const errmsg = isData(values[fieldname]); if (errmsg) return `'values.${fieldname}`+errmsg; } }; // Helper function for JavaScript runtime Type validation. const validateCreateValues = function(values: CreateValues): string | undefined { let errmsg = isValuesValidObject(values); if (errmsg) return `'values`+errmsg; for (let fieldname in values) { const field = values[fieldname]; const fieldType = type(field); switch (fieldType) { case null: case String: case Number: case Boolean: break; case Array: const errmsg = isArrayValue(field); if (errmsg) return `'values.${fieldname}`+errmsg; break; case Object: for (let itemId in field as CreatedItems) { const item = (field as CreatedItems)[itemId]; if (item === undefined || item === null || item.constructor !== Object) { return `'values.${fieldname}.${itemId}' is type ${pretty(fieldType)} but `+ `should be an Object.`; } for (let itemFieldname in item) { const errmsg = isData(item[itemFieldname]); if (errmsg) return `.${fieldname}.${itemId}.${itemFieldname}`+errmsg; } } break; default: return `'values.${fieldname}' is type ${pretty(fieldType)} but should be String, `+ `Number, Boolean, Object, Array or null when Audit 'operation' is "create".`; } } return undefined; }; // Helper function for JavaScript runtime Type validation. const validateEditValues = function(values: EditValues): string | undefined { let errmsg = isValuesValidObject(values); if (errmsg) return `'values`+errmsg; for (let fieldname in values) { // group of items or flat field. let field = values[fieldname] as EditedValue | ChangedItems; const fieldType = type(field); if (fieldType === null || fieldType === undefined || fieldType !== Object) { return `'values.${fieldname}' is type ${pretty(fieldType)} but should be an Object when `+ `Audit 'operation' is "edit".`; } const ret = isEditedValue(field); if (ret.constructor === String) return `'values.${fieldname}`+ret; if (ret) continue; // if 'ret' is true, this field is a valid 'EditedValue'. // if 'ret' is false, it's a group of items. // 'field' is a group of items inside 'values'. const items = field as ChangedItems; if (Object.keys(items).length === 0) { return `'values.${fieldname} cannot be an empty Object.`; } for (let itemId in items) { // for each item in group of items. const item = items[itemId] as ChangedItem; if (item === null) continue; // treating item deletion. const itemType = type(item); if (itemType !== undefined && itemType !== Object) { return `'values.${fieldname}.${itemId}' is type ${pretty(itemType)} `+ `but should be Object or null. '${itemId}' should be flatten to the root level `+ `of 'values' or there should be more layers between 'values' and '${itemId}'.`; } // all item fields must conform to either edit or create structure. // For each item field structure, we will check that it has the same structure as the others. let isEditedItem = false; let isCreatedItem = false; for (let itemFieldname in item) { // for field in item. if (itemFieldname === 'old' || itemFieldname === 'new') { return `'values.${fieldname}.${itemId}' has an Edit `+ `structure but should be an Object with the item "${itemId}" fields.`; } const data = item[itemFieldname]; const dataType = type(data); switch (dataType) { case Object: // type of 'EditedValue'. if (isCreatedItem) { // Mixed edit and create structure in item fields. return `'values.${fieldname}.${itemId}' seemed to be an item Create structure but `+ `item field '${itemFieldname}' belongs to an item Edit structure.`; } isEditedItem = true; // 'data' is obviously and Object but typescript doesn't get it. const ret = isEditedValue(data as Object); if (ret.constructor === String) { return `'values.${fieldname}.${itemId}.${itemFieldname}`+ret; } // the only valid value structure for 'data' is an edited value. if (!ret) { // if it's something else, returns error. return `'values.${fieldname}.${itemId}.${itemFieldname}' should have only `+ `'old' and 'new' as attributes.`; }; break; case null: case String: case Number: case Boolean: case Array: // types for 'Data'. if (isEditedItem) { // Mixed edit and create structure in item fields. return `'values.${fieldname}.${itemId}' seemed to be an item Edit structure but item `+ `field '${itemFieldname}' belongs to an item Create structure.`; } isCreatedItem = true; if (dataType === Array) { // narrowing down to 'ArrayValue'. const errmsg = isArrayValue(data); if (errmsg) { return `'values.${fieldname}.${itemId}.${itemFieldname}`+errmsg; } } break; default: return `'values.${fieldname}.${itemId}.${itemFieldname}' `+ `is type ${pretty(dataType)} but should be String, Number, Boolean, Array, Object `+ `or null when Audit 'operation' is "edit".`; } } } } }; // Helper function for JavaScript runtime type validation. const isValue = function(v: any): string | undefined { const T = type(v); switch (T) { case null: case String: case Number: case Boolean: return undefined; default: return `' is type ${pretty(T)} but should be String, Number, Boolean or null.`; } }; // Helper function for JavaScript runtime type validation. const isValueOrObject = function(el: any): string | undefined { const T = type(el); switch (T) { case null: case String: case Number: case Boolean: return undefined; case Object: for (let k in el) { const errmsg = isValue(el[k]); if (errmsg) return `.${k}`+errmsg; } return undefined; default: return `' is type ${pretty(T)} but should be String, Number, Boolean, null or Object.`; } }; // Helper function for JavaScript runtime type validation. const isArrayValue = function(arr: any): string | undefined { for (let i = 0; i < (arr as ArrayValue).length; i++) { const errmsg = isValueOrObject((arr as ArrayValue)[i]); if (errmsg) return `[${i}]`+errmsg; } }; // Helper function for JavaScript runtime type validation. const isData = function(data: any): string | undefined { const T = type(data); switch (T) { case null: case String: case Number: case Boolean: return undefined; case Array: return isArrayValue(data); default: return `' is type ${pretty(T)} but should be String, Number, Boolean, Array or null.`; } }; // Helper function for JavaScript runtime type validation. const isEditedValue = function(field: Object): string | boolean { const keysLength = Object.keys(field).length; if (keysLength === 0) return `' should not be an empty Object.`; const edit = field as EditedValue; // checking if 'field' is a bad edit structure. // if it has more than 2 keys, none of the keys should be 'old' or 'new' // or if it has exactly 2 keys, they should be those keys. if ( (keysLength > 2 && (edit.old !== undefined || edit.new !== undefined)) || (keysLength === 2 && ( (edit.old === undefined && edit.new !== undefined) || (edit.old !== undefined && edit.new === undefined)) ) ) return `' has 'old' and 'new' mixed with other attributes.`; // if field is an edit structure, it should have 2 keys being and both 'old' and 'new' // or 1 key being one of those keys. if ( (keysLength === 2 && edit.old !== undefined && edit.new !== undefined) || (keysLength === 1 && (edit.old !== undefined || edit.new !== undefined)) ) { let k: keyof EditedValue; for (k in edit) { const errmsg = isData(edit[k]); if (errmsg) return `.${k}`+errmsg; } return true; } return false; // definitely not an edit structure. it's something else. }; // Returns the constructor of the argument or null or undefined. const type = (x: any): any => x === null || x === undefined ? x : x.constructor; // Helper function for JavaScript runtime type validation. // Returns error string if argument is an non empty object, otherwise returns undefined. const isValuesValidObject = function(values: any): string | undefined { const valuesType = type(values); if (valuesType === undefined || valuesType === null || valuesType !== Object) { return `' is type ${valuesType} but should be an Object when Audit `+ `'operation' is not "delete".`; } if (Object.keys(values as Object).length === 0) return `' cannot be an empty Object.`; }; // given a group of objects that belong to a top level object and given a new group that will // replace it, this function returns an structure with all the changes. This structure is ready // to be sent in an AuditMessage as the values of a group of items in the 'values' attribute. export const buildAttributeChange = function (oldValue: any, newValue: any): Obj | undefined { // if both values are objects, we are dealing with a group of items in both sides. if (type(oldValue) === Object && type(newValue) === Object) { let changes: Obj = {}; // the structure to be returned. for (const id in newValue) { // for each item in 'newValue' group. let oldDataForId = oldValue[id]; let newDataForId = newValue[id]; if (oldDataForId !== undefined) { // if item exists in both groups. // if both items are Objects. if (type(oldDataForId) === Object && type(newDataForId) === Object) { let changesForId: Obj = {}; for (const field in newDataForId) { // for each key in item Object from 'newValue'. const oldVal = oldDataForId[field]; // referencing the same key in 'oldValue' item. const newVal = newDataForId[field]; // if they are the same, there is no change in this item. if (simpleEquals(oldVal, newVal)) continue; // if they are not the same, we build a change entry for that item. if (oldVal !== undefined) { // if value for that key in 'oldValue' exists. changesForId[field] = { // we build an entry with both 'old' and 'new' values. old: oldVal, new: newVal, }; } else { // if value for that key in 'oldValue' does not exist. changesForId[field] = {new: newVal}; // we build an entry with only 'new' value. } } for (const field in oldDataForId) { // for each key in item Object from 'oldValue'. // if value for that key in 'newValue' does not exist. if (newDataForId[field] === undefined) { // we build an entry with only 'old' value. changesForId[field] = {old: oldDataForId[field]}; } } // if any change for that item, we assign changes to item key in the returned structure. if (Object.keys(changesForId).length > 0) changes[id] = changesForId; } else { // if any of the two items is not an Object. // if they are the same, there is no change in this item. if (simpleEquals(oldDataForId, newDataForId)) continue; // if they are not the same, we build a change entry for that item. changes[id] = { old: oldDataForId, new: newDataForId, }; } } else { // if item does not exist in 'oldValues'. changes[id] = newDataForId; // we consider it as being created. } } for (const id in oldValue) { // for each item in 'oldValue' group. // if item does not exist in 'newValue', we consider it as deleted. if (newValue[id] === undefined) changes[id] = null; } if (Object.keys(changes).length === 0) return undefined; return changes; } // if any of the arguments is not an Object. // if they are the same, there is no change between these two arguments. if (simpleEquals(oldValue, newValue)) return undefined; // if they are not the same, one can still be undefined (but no both or they would be equal). // We return a structure containing the values that are not undefined. if (oldValue === undefined) return {new: newValue}; if (newValue === undefined) return {old: oldValue}; return {old: oldValue, new: newValue}; }