1 /* eslint-disable security/detect-object-injection */
4 const pgpInitOptions
= {
8 const path
= require('path');
9 const pgp
= require('pg-promise')(pgpInitOptions
);
10 const { unappliedSchemaVersions
} = require('../schema-version-helper');
11 const Database
= require('../abstract');
12 const DBErrors
= require('../errors');
13 const common
= require('../../common');
14 const Enum
= require('../../enum');
16 const _fileScope
= common
.fileScope(__filename
);
18 const PGTypeIdINT8
= 20; // Type Id 20 == INT8 (BIGINT)
19 const PGTypeIdINT8Array
= 1016; //Type Id 1016 == INT8[] (BIGINT[])
20 pgp
.pg
.types
.setTypeParser(PGTypeIdINT8
, BigInt
); // Type Id 20 = INT8 (BIGINT)
21 const parseBigIntArray
= pgp
.pg
.types
.getTypeParser(PGTypeIdINT8Array
); // Type Id 1016 = INT8[] (BIGINT[])
22 pgp
.pg
.types
.setTypeParser(PGTypeIdINT8Array
, (a
) => parseBigIntArray(a
).map(BigInt
));
24 const schemaVersionsSupported
= {
37 class DatabasePostgres
extends Database
{
38 constructor(logger
, options
, _pgp
= pgp
) {
39 super(logger
, options
);
41 this.db
= _pgp(options
.db
.connectionString
);
42 this.schemaVersionsSupported
= schemaVersionsSupported
;
44 // Suppress QF warnings when running tests
45 this.noWarnings
= options
.db
.noWarnings
;
48 const queryLogLevel
= options
.db
.queryLogLevel
;
50 const queryScope
= _fileScope('pgp:query');
51 pgpInitOptions
.query
= (event
) => {
52 this.logger
[queryLogLevel
](queryScope
, '', { ...common
.pick(event
|| {}, ['query', 'params']) });
57 const errorScope
= _fileScope('pgp:error');
58 pgpInitOptions
.error
= (err
, event
) => {
59 this.logger
.error(errorScope
, '', { err
, event
});
62 // Deophidiate column names in-place, log results
63 pgpInitOptions
.receive
= ({ data
, result
, ctx: event
}) => {
64 const exemplaryRow
= data
[0];
65 for (const prop
in exemplaryRow
) {
66 const camel
= common
.camelfy(prop
);
67 if (!(camel
in exemplaryRow
)) {
68 for (const d
of data
) {
76 const resultLog
= common
.pick(result
|| {}, ['command', 'rowCount', 'duration']);
77 this.logger
[queryLogLevel
](_fileScope('pgp:result'), '', { query: event
.query
, ...resultLog
});
81 // Expose these for test coverage
82 this.pgpInitOptions
= pgpInitOptions
;
85 this._initStatements(_pgp
);
89 _queryFileHelper(_pgp
) {
91 const _scope
= _fileScope('_queryFile');
92 /* istanbul ignore next */
95 ...(this.noWarnings
&& { noWarnings: this.noWarnings
}),
97 const qf
= new _pgp
.QueryFile(file
, qfParams
);
99 this.logger
.error(_scope
, 'failed to create SQL statement', { error: qf
.error
, file
});
107 async
initialize(applyMigrations
= true) {
108 const _scope
= _fileScope('initialize');
109 this.logger
.debug(_scope
, 'called', { applyMigrations
});
110 if (applyMigrations
) {
111 await
this._initTables();
113 await
super.initialize();
115 await
this.listener
.start();
120 async
_initTables(_pgp
) {
121 const _scope
= _fileScope('_initTables');
122 this.logger
.debug(_scope
, 'called', {});
124 const _queryFile
= this._queryFileHelper(_pgp
|| this._pgp
);
126 // Migrations rely upon this table, ensure it exists.
127 const metaVersionTable
= '_meta_schema_version';
129 const tableExists
= async (name
) => this.db
.oneOrNone('SELECT table_name FROM information_schema.tables WHERE table_name=$(name)', { name
});
130 let metaExists
= await
tableExists(metaVersionTable
);
132 const fPath
= path
.join(__dirname
, 'sql', 'schema', 'init.sql');
133 const initSql
= _queryFile(fPath
);
134 const results
= await
this.db
.multiResult(initSql
);
135 this.logger
.debug(_scope
, 'executed init sql', { results
});
136 metaExists
= await
tableExists(metaVersionTable
);
137 /* istanbul ignore if */
139 throw new DBErrors
.UnexpectedResult(`did not create ${metaVersionTable} table`);
141 this.logger
.info(_scope
, 'created schema version table', { metaVersionTable
});
145 const currentSchema
= await
this._currentSchema();
146 const migrationsWanted
= unappliedSchemaVersions(__dirname
, currentSchema
, this.schemaVersionsSupported
);
147 this.logger
.debug(_scope
, 'schema migrations wanted', { migrationsWanted
});
148 for (const v
of migrationsWanted
) {
149 const fPath
= path
.join(__dirname
, 'sql', 'schema', v
, 'apply.sql');
150 const migrationSql
= _queryFile(fPath
);
151 const results
= await
this.db
.multiResult(migrationSql
);
152 this.logger
.debug(_scope
, 'executed migration sql', { version: v
, results
});
153 this.logger
.info(_scope
, 'applied migration', { version: v
});
158 _initStatements(_pgp
) {
159 const _scope
= _fileScope('_initStatements');
160 const _queryFile
= this._queryFileHelper(_pgp
);
161 this.statement
= _pgp
.utils
.enumSql(path
.join(__dirname
, 'sql'), {}, _queryFile
);
162 this.logger
.debug(_scope
, 'statements initialized', { statements: Object
.keys(this.statement
).length
});
166 async
healthCheck() {
167 const _scope
= _fileScope('healthCheck');
168 this.logger
.debug(_scope
, 'called', {});
169 const c
= await
this.db
.connect();
171 return { serverVersion: c
.client
.serverVersion
};
175 async
_currentSchema() {
176 return this.db
.one('SELECT major, minor, patch FROM _meta_schema_version ORDER BY major DESC, minor DESC, patch DESC LIMIT 1');
180 async
_closeConnection() {
181 const _scope
= _fileScope('_closeConnection');
184 await
this.listener
.stop();
186 await
this._pgp
.end();
188 this.logger
.error(_scope
, 'failed', { error: e
});
194 /* istanbul ignore next */
195 async
_purgeTables(really
= false) {
196 const _scope
= _fileScope('_purgeTables');
199 await
this.db
.tx(async (t
) => {
207 ].map(async (table
) => t
.query('TRUNCATE TABLE $(table:name) CASCADE', { table
})));
211 this.logger
.error(_scope
, 'failed', { error: e
});
218 return this.db
.task(async (t
) => fn(t
));
222 // eslint-disable-next-line class-methods-use-this
223 async
transaction(dbCtx
, fn
) {
224 return dbCtx
.txIf(async (t
) => fn(t
));
228 async
almanacGetAll(dbCtx
) {
229 const _scope
= _fileScope('almanacGetAll');
230 this.logger
.debug(_scope
, 'called');
233 return await dbCtx
.manyOrNone(this.statement
.almanacGetAll
);
235 this.logger
.error(_scope
, 'failed', { error: e
});
241 async
almanacUpsert(dbCtx
, event
, date
) {
242 const _scope
= _fileScope('almanacUpsert');
243 this.logger
.debug(_scope
, 'called', { event
, date
});
246 const result
= await dbCtx
.result(this.statement
.almanacUpsert
, { event
, date: date
?? new Date() });
247 if (result
.rowCount
!= 1) {
248 throw new DBErrors
.UnexpectedResult('did not upsert almanac event');
251 this.logger
.error(_scope
, 'failed', { error: e
, event
, date
});
257 async
authenticationGet(dbCtx
, identifier
) {
258 const _scope
= _fileScope('authenticationGet');
259 this.logger
.debug(_scope
, 'called', { identifier
});
262 return await dbCtx
.oneOrNone(this.statement
.authenticationGet
, { identifier
});
264 this.logger
.error(_scope
, 'failed', { error: e
, identifier
});
270 async
authenticationSuccess(dbCtx
, identifier
) {
271 const _scope
= _fileScope('authenticationSuccess');
272 this.logger
.debug(_scope
, 'called', { identifier
});
275 const result
= await dbCtx
.result(this.statement
.authenticationSuccess
, { identifier
});
276 if (result
.rowCount
!= 1) {
277 throw new DBErrors
.UnexpectedResult('did not update authentication success event');
280 this.logger
.error(_scope
, 'failed', { error: e
, identifier
});
286 async
authenticationUpsert(dbCtx
, identifier
, credential
) {
287 const _scope
= _fileScope('authenticationUpsert');
288 const scrubbedCredential
= '*'.repeat((credential
|| '').length
);
289 this.logger
.debug(_scope
, 'called', { identifier
, scrubbedCredential
});
292 const result
= await dbCtx
.result(this.statement
.authenticationUpsert
, { identifier
, credential
});
293 if (result
.rowCount
!= 1) {
294 throw new DBErrors
.UnexpectedResult('did not upsert authentication');
297 this.logger
.error(_scope
, 'failed', { error: e
, identifier
, scrubbedCredential
});
303 async
profileIdentifierInsert(dbCtx
, profile
, identifier
) {
304 const _scope
= _fileScope('profileIdentifierInsert');
305 this.logger
.debug(_scope
, 'called', { profile
, identifier
});
308 const result
= await dbCtx
.result(this.statement
.profileIdentifierInsert
, { profile
, identifier
});
309 if (result
.rowCount
!= 1) {
310 throw new DBErrors
.UnexpectedResult('did not insert identifier');
313 this.logger
.error(_scope
, 'failed', { error: e
, profile
, identifier
});
319 async
profileIsValid(dbCtx
, profile
) {
320 const _scope
= _fileScope('profileIsValid');
321 this.logger
.debug(_scope
, 'called', { profile
});
324 const profileResponse
= await dbCtx
.oneOrNone(this.statement
.profileGet
, { profile
});
325 return !!profileResponse
;
327 this.logger
.error(_scope
, 'failed', { error: e
, profile
});
333 async
profileScopeInsert(dbCtx
, profile
, scope
) {
334 const _scope
= _fileScope('profileScopeInsert');
335 this.logger
.debug(_scope
, 'called', { profile
, scope
});
338 const result
= await dbCtx
.result(this.statement
.profileScopeInsert
, { profile
, scope
});
339 // Duplicate inserts get ignored
340 if (result
.rowCount
!= 1 && result
.rowCount
!= 0) {
341 throw new DBErrors
.UnexpectedResult('did not insert profile scope');
344 this.logger
.error(_scope
, 'failed', { error: e
, profile
, scope
});
350 async
profileScopesSetAll(dbCtx
, profile
, scopes
) {
351 const _scope
= _fileScope('profileScopesSetAll');
352 this.logger
.debug(_scope
, 'called', { profile
, scopes
});
355 await
this.transaction(dbCtx
, async (txCtx
) => {
356 await txCtx
.result(this.statement
.profileScopesClear
, { profile
});
358 await txCtx
.result(this.statement
.profileScopesSetAll
, { profile
, scopes
});
362 this.logger
.error(_scope
, 'failed', { error: e
, profile
, scopes
});
368 async
profilesScopesByIdentifier(dbCtx
, identifier
) {
369 const _scope
= _fileScope('profilesScopesByIdentifier');
370 this.logger
.debug(_scope
, 'called', { identifier
});
373 const profileScopesRows
= await dbCtx
.manyOrNone(this.statement
.profilesScopesByIdentifier
, { identifier
});
374 return Database
._profilesScopesBuilder(profileScopesRows
);
376 this.logger
.error(_scope
, 'failed', { error: e
, identifier
});
382 async
redeemCode(dbCtx
, { codeId
, created
, isToken
, clientId
, profile
, identifier
, scopes
, lifespanSeconds
, refreshLifespanSeconds
, resource
, profileData
}) {
383 const _scope
= _fileScope('redeemCode');
384 this.logger
.debug(_scope
, 'called', { codeId
, created
, isToken
, clientId
, profile
, identifier
, scopes
, lifespanSeconds
, refreshLifespanSeconds
, resource
, profileData
});
386 let result
, ret
= false;
388 await
this.transaction(dbCtx
, async (txCtx
) => {
389 result
= await txCtx
.result(this.statement
.redeemCode
, { codeId
, created
, isToken
, clientId
, profile
, identifier
, lifespanSeconds
, refreshLifespanSeconds
, resource
, profileData
});
390 if (result
.rowCount
!= 1) {
391 this.logger
.error(_scope
, 'failed', { result
});
392 throw new DBErrors
.UnexpectedResult('did not redeem code');
394 // Abort and return false if redemption resulted in revocation.
395 if (result
.rows
[0].isRevoked
) {
398 this.logger
.debug(_scope
, 'code redeemed', { redeemed: result
.rows
[0] });
400 // Ensure there are entries for all scopes.
401 if (scopes
.length
!== 0) {
402 await txCtx
.result(this.statement
.scopesInsert
, { scopes
});
405 // Record accepted scopes for this token.
406 result
= await txCtx
.result(this.statement
.tokenScopesSet
, { codeId
, scopes
});
407 if (result
.rowCount
!= scopes
.length
) {
408 this.logger
.error(_scope
, 'token scope count mismatch', { codeId
, scopes
, result
});
409 throw new DBErrors
.UnexpectedResult('did not set all scopes on token');
414 this.logger
.error(_scope
, 'failed', { error: e
, codeId
, created
, isToken
, clientId
, profile
, identifier
, scopes
, lifespanSeconds
, refreshLifespanSeconds
, profileData
});
422 async
refreshCode(dbCtx
, codeId
, refreshed
, removeScopes
) {
423 const _scope
= _fileScope('refreshCode');
424 this.logger
.debug(_scope
, 'called', { codeId
, refreshed
, removeScopes
});
427 return await
this.transaction(dbCtx
, async (txCtx
) => {
428 const refreshedToken
= await txCtx
.oneOrNone(this.statement
.refreshCode
, { codeId
, refreshed
});
429 if (refreshedToken
) {
430 if (removeScopes
.length
) {
431 const removeResult
= await txCtx
.result(this.statement
.tokenScopesRemove
, { codeId
, removeScopes
});
432 if (removeResult
.rowCount
!= removeScopes
.length
) {
433 this.logger
.error(_scope
, 'failed to remove token scopes', { actual: removeResult
.rowCount
, expected: removeScopes
.length
});
434 throw new DBErrors
.UnexpectedResult('did not remove scopes from token');
437 delete refreshedToken
.scopes
; // Not updated, remove from response.
440 this.logger
.debug(_scope
, 'did not refresh token', {});
442 return refreshedToken
;
445 this.logger
.error(_scope
, 'failed', { error: e
, codeId
});
451 async
resourceGet(dbCtx
, resourceId
) {
452 const _scope
= _fileScope('resourceGet');
453 this.logger
.debug(_scope
, 'called', { resourceId
});
456 return await dbCtx
.oneOrNone(this.statement
.resourceGet
, { resourceId
});
458 this.logger
.error(_scope
, 'failed', { error: e
, resourceId
});
464 async
resourceUpsert(dbCtx
, resourceId
, secret
, description
) {
465 const _scope
= _fileScope('resourceUpsert');
466 const logSecret
= secret
?.length
&& common
.logTruncate('*'.repeat(secret
.length
), 3) || undefined;
467 this.logger
.debug(_scope
, 'called', { resourceId
, secret: logSecret
, description
});
470 const result
= await dbCtx
.result(this.statement
.resourceUpsert
, { resourceId
, secret
, description
});
471 if (result
.rowCount
!= 1) {
472 throw new DBErrors
.UnexpectedResult('did not upsert resource');
474 return result
.rows
[0];
476 this.logger
.error(_scope
, 'failed', { error: e
, resourceId
, secret: logSecret
, description
});
482 async
scopeCleanup(dbCtx
, atLeastMsSinceLast
) {
483 const _scope
= _fileScope('scopeCleanup');
484 this.logger
.debug(_scope
, 'called', { atLeastMsSinceLast
});
486 const almanacEvent
= Enum
.AlmanacEntry
.ScopeCleanup
;
488 return await
this.transaction(dbCtx
, async (txCtx
) => {
490 // Check that enough time has passed since last cleanup
491 const now
= new Date();
492 const cleanupNotAfter
= new Date(now
.getTime() - atLeastMsSinceLast
);
493 const { date: lastCleanupDate
} = await txCtx
.oneOrNone(this.statement
.almanacGet
, { event: almanacEvent
}) || { date: new Date(0) };
494 if (lastCleanupDate
>= cleanupNotAfter
) {
495 this.logger
.debug(_scope
, 'skipping token cleanup, too soon', { lastCleanupDate
, cleanupNotAfter
, atLeastMsSinceLast
});
500 const { rowCount: scopesRemoved
} = await txCtx
.result(this.statement
.scopeCleanup
);
502 // Update the last cleanup time
503 const result
= await txCtx
.result(this.statement
.almanacUpsert
, { event: almanacEvent
, date: now
});
504 if (result
.rowCount
!= 1) {
505 throw new DBErrors
.UnexpectedResult('did not update almanac');
508 this.logger
.debug(_scope
, 'completed', { scopesRemoved
, atLeastMsSinceLast
});
509 return scopesRemoved
;
513 this.logger
.error(_scope
, 'failed', { error: e
, atLeastMsSinceLast
});
519 async
scopeDelete(dbCtx
, scope
) {
520 const _scope
= _fileScope('scopeDelete');
521 this.logger
.debug(_scope
, 'called', { scope
});
524 return await
this.transaction(dbCtx
, async (txCtx
) => {
525 const { inUse
} = await txCtx
.one(this.statement
.scopeInUse
, { scope
});
527 this.logger
.debug(_scope
, 'not deleted, in use', { scope
});
530 const result
= await txCtx
.result(this.statement
.scopeDelete
, { scope
});
531 if (result
.rowCount
== 0) {
532 this.logger
.debug(_scope
, 'no such scope', { scope
});
534 this.logger
.debug(_scope
, 'deleted', { scope
});
539 this.logger
.error(_scope
, 'failed', { error: e
, scope
});
545 async
scopeUpsert(dbCtx
, scope
, application
, description
, manuallyAdded
= false) {
546 const _scope
= _fileScope('scopeUpsert');
547 this.logger
.debug(_scope
, 'called', { scope
, description
});
550 const result
= await dbCtx
.result(this.statement
.scopeUpsert
, { scope
, application
, description
, manuallyAdded
});
551 if (result
.rowCount
!= 1) {
552 throw new DBErrors
.UnexpectedResult('did not upsert scope');
555 this.logger
.error(_scope
, 'failed', { error: e
, scope
, application
, description
});
561 async
tokenCleanup(dbCtx
, codeLifespanSeconds
, atLeastMsSinceLast
) {
562 const _scope
= _fileScope('tokenCleanup');
563 this.logger
.debug(_scope
, 'called', { codeLifespanSeconds
, atLeastMsSinceLast
});
565 const almanacEvent
= Enum
.AlmanacEntry
.TokenCleanup
;
567 return await
this.transaction(dbCtx
, async (txCtx
) => {
569 // Check that enough time has passed since last cleanup
570 const now
= new Date();
571 const cleanupNotAfter
= new Date(now
.getTime() - atLeastMsSinceLast
);
572 const { date: lastCleanupDate
} = await txCtx
.oneOrNone(this.statement
.almanacGet
, { event: almanacEvent
}) || { date: new Date(0) };
573 if (lastCleanupDate
>= cleanupNotAfter
) {
574 this.logger
.debug(_scope
, 'skipping token cleanup, too soon', { lastCleanupDate
, cleanupNotAfter
, codeLifespanSeconds
, atLeastMsSinceLast
});
579 const { rowCount: tokensRemoved
} = await txCtx
.result(this.statement
.tokenCleanup
, { codeLifespanSeconds
});
581 // Update the last cleanup time
582 const result
= await txCtx
.result(this.statement
.almanacUpsert
, { event: almanacEvent
, date: now
});
583 if (result
.rowCount
!= 1) {
584 throw new DBErrors
.UnexpectedResult('did not update almanac');
587 this.logger
.debug(_scope
, 'completed', { tokensRemoved
, codeLifespanSeconds
, atLeastMsSinceLast
});
588 return tokensRemoved
;
592 this.logger
.error(_scope
, 'failed', { error: e
, atLeastMsSinceLast
});
598 async
tokenGetByCodeId(dbCtx
, codeId
) {
599 const _scope
= _fileScope('tokenGetByCodeId');
600 this.logger
.debug(_scope
, 'called', { codeId
});
603 return await dbCtx
.oneOrNone(this.statement
.tokenGetByCodeId
, { codeId
});
605 this.logger
.error(_scope
, 'failed', { error: e
, codeId
});
611 async
tokenRevokeByCodeId(dbCtx
, codeId
) {
612 const _scope
= _fileScope('tokenRevokeByCodeId');
613 this.logger
.debug(_scope
, 'called', { codeId
});
616 const result
= await dbCtx
.result(this.statement
.tokenRevokeByCodeId
, { codeId
});
617 if (result
.rowCount
!= 1) {
618 throw new DBErrors
.UnexpectedResult('did not revoke token');
621 this.logger
.error(_scope
, 'failed', { error: e
, codeId
});
627 async
tokenRefreshRevokeByCodeId(dbCtx
, codeId
) {
628 const _scope
= _fileScope('tokenRefreshRevokeByCodeId');
629 this.logger
.debug(_scope
, 'called', { codeId
});
632 const result
= await dbCtx
.result(this.statement
.tokenRefreshRevokeByCodeId
, { codeId
});
633 if (result
.rowCount
!= 1) {
634 throw new DBErrors
.UnexpectedResult('did not revoke token');
637 this.logger
.error(_scope
, 'failed', { error: e
, codeId
});
643 async
tokensGetByIdentifier(dbCtx
, identifier
) {
644 const _scope
= _fileScope('tokensGetByIdentifier');
645 this.logger
.debug(_scope
, 'called', { identifier
});
648 return await dbCtx
.manyOrNone(this.statement
.tokensGetByIdentifier
, { identifier
});
650 this.logger
.error(_scope
, 'failed', { error: e
, identifier
});
656 async
ticketRedeemed(dbCtx
, redeemedData
) {
657 const _scope
= _fileScope('ticketRedeemed');
658 this.logger
.debug(_scope
, 'called', { ...redeemedData
});
661 const result
= await dbCtx
.result(this.statement
.ticketRedeemed
, redeemedData
);
662 if (result
.rowCount
!= 1) {
663 throw new DBErrors
.UnexpectedResult('did not store redeemed ticket');
666 this.logger
.error(_scope
, 'failed', { error: e
, ...redeemedData
});
672 async
ticketTokenPublished(dbCtx
, redeemedData
) {
673 const _scope
= _fileScope('ticketRedeemed');
674 this.logger
.debug(_scope
, 'called', { ...redeemedData
});
676 const almanacEvent
= Enum
.AlmanacEntry
.TicketPublished
;
678 const result
= await dbCtx
.result(this.statement
.ticketTokenPublished
, redeemedData
);
679 if (result
.rowCount
!= 1) {
680 throw new DBErrors
.UnexpectedResult('did not store redeemed ticket');
682 const almanacResult
= await dbCtx
.result(this.statement
.almanacUpsert
, { event: almanacEvent
, date: new Date() });
683 if (almanacResult
.rowCount
!= 1) {
684 throw new DBErrors
.UnexpectedResult('did not update almanac');
687 this.logger
.error(_scope
, 'failed', { error: e
, ...redeemedData
});
692 async
ticketTokenGetUnpublished(dbCtx
) {
693 const _scope
= _fileScope('ticketTokenGetUnpublished');
694 this.logger
.debug(_scope
, 'called');
697 return await dbCtx
.manyOrNone(this.statement
.ticketTokenGetUnpublished
);
699 this.logger
.error(_scope
, 'failed', { error: e
});
706 module
.exports
= DatabasePostgres
;