Skip to content

Commit

Permalink
DB connection WIP
Browse files Browse the repository at this point in the history
  • Loading branch information
kyle1morel committed Dec 8, 2023
1 parent 7fd235e commit 648f433
Show file tree
Hide file tree
Showing 14 changed files with 1,049 additions and 13 deletions.
493 changes: 480 additions & 13 deletions app/package-lock.json

Large diffs are not rendered by default.

7 changes: 7 additions & 0 deletions app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,16 +31,23 @@
"@types/jest": "^29.5.8",
"@types/jsonwebtoken": "^9.0.5",
"@types/node": "^20.9.0",
"@types/pg": "^8.10.9",
"@types/uuid": "^9.0.7",
"api-problem": "^9.0.2",
"axios": "^1.5.1",
"compression": "^1.7.4",
"config": "^3.3.9",
"cors": "^2.8.5",
"date-fns": "^2.30.0",
"express": "^4.18.2",
"express-winston": "^4.2.0",
"helmet": "^7.0.0",
"jsonwebtoken": "^9.0.2",
"knex": "^3.1.0",
"objection": "^3.1.3",
"pg": "^8.11.3",
"ts-node": "^10.9.1",
"uuid": "^9.0.1",
"winston": "^3.11.0",
"winston-transport": "^4.6.0"
},
Expand Down
80 changes: 80 additions & 0 deletions app/src/components/crypt.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import config from 'config';
import crypto from 'crypto';

// GCM mode is good for situations with random access and authenticity requirements
// CBC mode is older, but is sufficiently secure with high performance for short payloads
const algorithm = 'aes-256-cbc';
const encoding = 'base64';
const encodingCheck = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{4}|[A-Za-z0-9+/]{3}=|[A-Za-z0-9+/]{2}={2})$/;
const hashAlgorithm = 'sha256';

/**
* @function encrypt
* Yields an encrypted string containing the iv and ciphertext, separated by a colon.
* If no key is provided, ciphertext will be the plaintext in base64 encoding.
* @param {string} text The input string contents
* @returns {string} The encrypted base64 formatted string in the format `iv:ciphertext`.
*/
export function encrypt(text: string) {
if (isEncrypted(text)) return text;

const passphrase = config.has('server.passphrase') ? (config.get('server.passphrase') as string) : undefined;
if (passphrase && passphrase.length) {
let content = Buffer.from(text);
const iv = crypto.randomBytes(16);
const hash = crypto.createHash(hashAlgorithm);
// AES-256 key length must be exactly 32 bytes
const key = hash.update(passphrase).digest().subarray(0, 32);
const cipher = crypto.createCipheriv(algorithm, key, iv);
content = Buffer.concat([cipher.update(text), cipher.final()]);
return `${iv.toString(encoding)}:${content.toString(encoding)}`;
} else {
return text;
}
}

/**
* @function decrypt
* Yields the plaintext by accepting an encrypted string containing the iv and
* ciphertext, separated by a colon. If no key is provided, the plaintext will be
* the ciphertext.
* @param {string} text The input encrypted string contents
* @returns {string} The decrypted plaintext string, usually in utf-8
*/
export function decrypt(text: string) {
if (!isEncrypted(text)) return text;

const passphrase = config.has('server.passphrase') ? (config.get('server.passphrase') as string) : undefined;
if (passphrase && passphrase.length) {
const [iv, encrypted] = text.split(':').map((p) => Buffer.from(p, encoding));
let content = encrypted;
const hash = crypto.createHash(hashAlgorithm);
// AES-256 key length must be exactly 32 bytes
const key = hash.update(passphrase).digest().subarray(0, 32);
const decipher = crypto.createDecipheriv(algorithm, key, iv);
content = Buffer.concat([decipher.update(encrypted), decipher.final()]);
return Buffer.from(content, encoding).toString();
} else {
return text;
}
}

/**
* @function isEncrypted
* A predicate function for determining if the input text is encrypted
* @param {string} text The input string contents
* @returns {boolean} True if encrypted, false if not
*/
export function isEncrypted(text: string) {
if (!text) return false;
if (typeof text !== 'string') return false;
const textParts = text.split(':');
return (
textParts.length == 2 &&
textParts[0] &&
textParts[1] &&
textParts[0].length === 24 && // Base64 encoding of a 16 byte IV should be 24
encodingCheck.test(textParts[0]) &&
encodingCheck.test(textParts[1])
);
}
2 changes: 2 additions & 0 deletions app/src/db/.editorconfig
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
[*.js]
indent_size = unset
176 changes: 176 additions & 0 deletions app/src/db/dataConnection.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
import { Knex, knex } from 'knex';
import { Model } from 'objection';

import { getLogger } from '../components/log';
import { knexConfig } from '../knexfile';
import models from './models';

const log = getLogger(module.filename);

export default class DataConnection {
/**
* Creates a new DataConnection with default (Postgresql) Knex configuration.
* @class
*/
private static _instance: DataConnection;
private _knex: Knex;
private _connected: boolean = false;

constructor() {
if (!DataConnection._instance) {
this._knex = knex(knexConfig);
DataConnection._instance = this;
}

return DataConnection._instance;
}

/**
* @function connected
* True or false if connected.
*/
get connected() {
return this._connected;
}

/**
* @function knex
* Gets the current knex binding
*/
get knex() {
return this._knex;
}

/**
* @function knex
* Sets the current knex binding and forwards to Objection model
* @param {object} v - a Knex object.
*/
set knex(v) {
this._knex = v;
this._connected = false;
Model.knex(this._knex);
}

/**
* @function checkAll
* Checks the Knex connection, the database schema, and Objection models
* @returns {boolean} True if successful, otherwise false
*/
async checkAll() {
const modelsOk = !!this._knex;
const [connectOk, schemaOk] = await Promise.all([this.checkConnection(), this.checkSchema()]);
this._connected = connectOk && schemaOk && modelsOk;
log.verbose(`Connect OK: ${connectOk}, Schema OK: ${schemaOk}, Models OK: ${modelsOk}`, { function: 'checkAll' });

if (!connectOk) {
log.error('Could not connect to the database, check configuration and ensure database server is running', {
function: 'checkAll'
});
}
if (!schemaOk) {
log.error('Connected to the database, could not verify the schema. Ensure proper migrations have been run.', {
function: 'checkAll'
});
}
if (!modelsOk) {
log.error('Connected to the database, schema is ok, could not initialize Knex Models.', { function: 'checkAll' });
}

return this._connected;
}

/**
* @function checkConnection
* Checks the current knex connection to Postgres
* If the connected DB is in read-only mode, transaction_read_only will not be off
* @returns {boolean} True if successful, otherwise false
*/
async checkConnection() {
try {
const data = await this._knex.raw('show transaction_read_only');
const result = data?.rows[0]?.transaction_read_only === 'off';
if (result) {
log.debug('Database connection ok', { function: 'checkConnection' });
} else {
log.warn('Database connection is read-only', { function: 'checkConnection' });
}
this._connected = result;
return result;
} catch (err) {
log.error(`Error with database connection: ${err.message}`, { function: 'checkConnection' });
this._connected = false;
return false;
}
}

/**
* @function checkSchema
* Queries the knex connection to check for the existence of the expected schema tables
* @returns {boolean} True if schema is ok, otherwise false
*/
checkSchema() {
try {
const tables = Object.values(models).map((model) => model.tableName);
return Promise.all(
tables.map((table) =>
Promise.all(knexConfig.searchPath.map((schema) => this._knex.schema.withSchema(schema).hasTable(table)))
)
)
.then((exists) => exists.every((table) => table.some((exist) => exist)))
.then((result) => {
if (result) log.debug('Database schema ok', { function: 'checkSchema' });
return result;
});
} catch (err) {
log.error(`Error with database schema: ${err.message}`, { function: 'checkSchema' });
log.error(err);
return false;
}
}

/**
* @function checkModel
* Attaches the Objection model to the existing knex connection
* @returns {boolean} True if successful, otherwise false
*/
checkModel() {
try {
Model.knex(this._knex);
log.debug('Database models ok', { function: 'checkModel' });
return true;
} catch (err) {
log.error(`Error attaching Model to connection: ${err.message}`, { function: 'checkModel' });
log.error(err);
return false;
}
}

/**
* @function close
* Will close the DataConnection
* @param {function} [cb] Optional callback
*/
close(cb = undefined) {
if (this._knex) {
this._knex.destroy(() => {
this._knex = undefined;
log.info('Disconnected', { function: 'close' });
if (cb) cb();
});
} else if (cb) cb();
}

/**
* @function resetConnection
* Invalidates and reconnects existing knex connection
*/
resetConnection() {
if (this._knex) {
log.warn('Attempting to reset database connection pool', { function: 'resetConnection' });
this._knex.destroy(() => {
this._knex.initialize();
});
}
}
}
8 changes: 8 additions & 0 deletions app/src/db/models/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import Submission from './tables/submission';

const models = {
// Tables
Submission: Submission
};

export default { models };
8 changes: 8 additions & 0 deletions app/src/db/models/jsonSchema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
const stamps = {
createdBy: { type: ['string', 'null'], maxLength: 255 },
createdAt: { type: ['string', 'null'] },
updatedBy: { type: ['string', 'null'], maxLength: 255 },
updatedAt: { type: ['string', 'null'] }
};

export default { stamps };
68 changes: 68 additions & 0 deletions app/src/db/models/mixins/encrypt.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { encrypt, decrypt } from '../../../components/crypt';

/**
* Encrypt Objection Model Plugin
* Add column encryption handlers to an Objection Model
*
* This class will automatically encrypt and decrypt specified column fields
* during insert/update/get operations transparently.
* Inspired by @see {@link https://github.com/Dialogtrail/objection-encrypt}
*
* @see module:knex
* @see module:objection
*/
export default function Encrypt(opts) {
// Provide good default options if possible.
const options = Object.assign(
{
fields: []
},
opts
);

// Return the mixin
return (Model) => {
return class extends Model {
async $beforeInsert(context) {
await super.$beforeInsert(context);
this.encryptFields();
}
async $afterInsert(context) {
await super.$afterInsert(context);
return this.decryptFields();
}
async $beforeUpdate(queryOptions, context) {
await super.$beforeUpdate(queryOptions, context);
this.encryptFields();
}
async $afterUpdate(queryOptions, context) {
await super.$afterUpdate(queryOptions, context);
return this.decryptFields();
}
async $afterFind(context) {
await super.$afterFind(context);
return this.decryptFields();
}

/**
* Encrypts specified fields
*/
encryptFields() {
options.fields.forEach((field) => {
const value = this[field];
if (value) this[field] = encrypt(value);
});
}

/**
* Decrypts specified fields
*/
decryptFields() {
options.fields.forEach((field) => {
const value = this[field];
if (value) this[field] = decrypt(value);
});
}
};
};
}
2 changes: 2 additions & 0 deletions app/src/db/models/mixins/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { default as Encrypt } from './encrypt';
export { default as Timestamps } from './timestamps';
Loading

0 comments on commit 648f433

Please sign in to comment.