@nearform/trail-core
Version:
Audit trail logging service
271 lines (216 loc) • 8.31 kB
JavaScript
const SQL = require('@nearform/sql')
const pino = require('pino')
const { Pool } = require('pg')
require('pg').defaults.parseInt8 = true
const defaultPageSize = 25
const { parseDate, convertToTrail } = require('./trail')
const defaultDBPoolSettings = {
host: 'localhost',
port: 5432,
database: 'trails',
user: 'postgres',
password: 'postgres',
max: 10,
idleTimeoutMillis: 30000
}
class TrailsManager {
constructor (opts) {
const {
logger = pino(),
db = {},
pool = new Pool({ ...defaultDBPoolSettings, ...db })
} = opts
this.logger = logger
this.dbPool = pool
}
async close () {
return this.dbPool.end()
}
async performDatabaseOperations (operations, useTransaction = true) {
let client = null
try {
// Connect to the pool, then perform the operations
client = await this.dbPool.connect()
if (useTransaction) await client.query('BEGIN')
const result = typeof operations === 'function' ? await operations(client) : await client.query(operations)
// Release the client, the return the result
if (useTransaction) await client.query('COMMIT')
client.release()
return result
} catch (e) {
// If connection succeded, release the client
if (client) {
if (useTransaction) await client.query('ROLLBACK')
client.release()
}
// Propagate any rejection
throw e
}
}
async search ({ from, to, who, what, subject, page, pageSize, sort, exactMatch = false, caseInsensitive = false } = {}) {
// Validate parameters
if (!from) throw new Error('You must specify a starting date ("from" attribute) when querying trails.')
if (!to) throw new Error('You must specify a ending date ("to" attribute) when querying trails.')
if (who && typeof who !== 'string') throw new TypeError('Only strings are supporting for searching in the id of the "who" field.')
if (what && typeof what !== 'string') throw new TypeError('Only strings are supporting for searching in the id of the "what" field.')
if (subject && typeof subject !== 'string') throw new TypeError('Only strings are supporting for searching in the id of the "subject" field.')
from = parseDate(from)
to = parseDate(to)
// Sanitize pagination parameters
;({ page, pageSize } = this._sanitizePagination(page, pageSize))
// Sanitize ordering
const { sortKey, sortAsc } = this._sanitizeSorting(sort)
// Perform the query
const sql = SQL`
SELECT
id::int, timezone('UTC', "when") as "when",
who_id, what_id, subject_id,
who_data as who, what_data as what, subject_data as subject,
"where", why, meta
FROM trails
`
// Prepare the query to get Count
const sqlCount = SQL`
SELECT
count(*)
FROM trails
`
const op = caseInsensitive ? 'ILIKE' : 'LIKE'
const filterClause = SQL` WHERE
("when" >= ${from.toISO()} AND "when" <= ${to.toISO()})`
if (who) filterClause.append(SQL([` AND who_id ${op} `]).append(SQL`${exactMatch ? who : '%' + who + '%'}`))
if (what) filterClause.append(SQL([` AND what_id ${op} `]).append(SQL`${exactMatch ? what : '%' + what + '%'}`))
if (subject) filterClause.append(SQL([` AND subject_id ${op} `]).append(SQL`${exactMatch ? subject : '%' + subject + '%'}`))
sql.append(filterClause)
sqlCount.append(filterClause)
const footer = ` ORDER BY ${sortKey} ${sortAsc ? 'ASC' : 'DESC'} LIMIT ${pageSize} OFFSET ${(page - 1) * pageSize}`
sql.append(SQL([footer]))
const res = await this.performDatabaseOperations(client => {
return Promise.all([
client.query(sqlCount),
client.query(sql)
])
})
return { count: res[0].rows[0].count, data: res[1].rows.map(convertToTrail) }
}
async enumerate ({ from, to, type, page, pageSize, desc } = {}) {
// Validate parameters
if (!from) throw new Error('You must specify a starting date ("from" attribute) when enumerating.')
if (!to) throw new Error('You must specify a ending date ("to" attribute) when enumerating.')
from = parseDate(from)
to = parseDate(to)
/*
WARNING - @paolo on 2018-05-17
The type parameter is used below to build the SELECT query. Due to its dynamic nature
it is inserted without the @nearform/sql SQL injection protection.
If you change the logic here make sure you don't create a security vulnerability.
*/
if (!['who', 'what', 'subject'].includes(type)) throw new TypeError('You must select between "who", "what" or "subject" type ("type" attribute) when enumerating.')
// Sanitize pagination parameters
;({ page, pageSize } = this._sanitizePagination(page, pageSize))
// Perform the query
const sql = SQL`
SELECT
DISTINCT ON($type$_id) $type$_id AS entry
FROM trails
WHERE
("when" >= ${from.toISO()} AND "when" <= ${to.toISO()})
`
const strings = Array.from(sql.strings)
strings.splice(0, 1, strings[0].replace(/\$type\$/g, type))
sql.strings = strings
const footer = ` ORDER BY entry ${desc ? 'DESC' : 'ASC'} LIMIT ${pageSize} OFFSET ${(page - 1) * pageSize}`
sql.append(SQL([footer]))
const res = await this.performDatabaseOperations(sql)
return res.rows.map(r => r.entry)
}
async insert (trail) {
trail = convertToTrail(trail)
const sql = SQL`
INSERT
INTO trails ("when", who_id, what_id, subject_id, who_data, what_data, subject_data, "where", why, meta)
VALUES (
${trail.when.toISO()},
${trail.who.id},
${trail.what.id},
${trail.subject.id},
${trail.who.attributes},
${trail.what.attributes},
${trail.subject.attributes},
${trail.where},
${trail.why},
${trail.meta}
)
RETURNING id::int;
`
const res = await this.performDatabaseOperations(sql)
return res.rows[0].id
}
async get (id) {
const sql = SQL`
SELECT
id::int,
timezone('UTC', "when") as "when",
who_id, what_id, subject_id,
who_data as who, what_data as what, subject_data as subject,
"where", why, meta
FROM trails
WHERE id = ${id}
`
const res = await this.performDatabaseOperations(sql)
return res.rowCount > 0 ? convertToTrail(res.rows[0]) : null
}
async update (id, trail) {
trail = convertToTrail(trail)
const sql = SQL`
UPDATE trails
SET
"when" = ${trail.when.toISO()},
who_id = ${trail.who.id},
what_id = ${trail.what.id},
subject_id = ${trail.subject.id},
who_data = ${trail.who.attributes},
subject_data = ${trail.subject.attributes},
what_data = ${trail.what.attributes},
"where" = ${trail.where},
why = ${trail.why},
meta = ${trail.meta}
WHERE id = ${id}
`
const res = await this.performDatabaseOperations(sql)
return res.rowCount !== 0
}
async delete (id) {
const sql = SQL`
DELETE
FROM trails
WHERE id = ${id}
`
const res = await this.performDatabaseOperations(sql)
return res.rowCount !== 0
}
_sanitizeSorting (sortKey) {
let sortAsc = true
if (!sortKey) return { sortKey: '"when"', sortAsc: false } // Default is -when
if (sortKey.startsWith('-')) {
sortAsc = false
sortKey = sortKey.substring(1)
}
if (!['id', 'when', 'who', 'what', 'subject'].includes(sortKey)) {
throw new TypeError('Only "id", "when", "who", "what" and "subject" are supported for sorting.')
}
// Perform some sanitization
if (sortKey === 'when') sortKey = '"when"'
else if (sortKey !== 'id') sortKey += '_id'
return { sortKey: `${sortKey}`, sortAsc }
}
_sanitizePagination (page, pageSize) {
page = typeof page === 'number' ? page : parseInt(page, 0)
pageSize = typeof pageSize !== 'number' ? pageSize : parseInt(pageSize, 0)
if (isNaN(page) || page < 1) { page = 1 }
if (isNaN(pageSize) || pageSize < 1) { pageSize = defaultPageSize }
return { page, pageSize }
}
}
module.exports = { TrailsManager }