@dhyasama/totem-merge 7.0.3 → 7.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/bootstrap +11 -0
- package/config.js +1 -5
- package/index.js +0 -6
- package/infrastructure/security-checks/runtime-secret-contract.cjs +91 -0
- package/infrastructure/security-checks/runtime-secrets.md +16 -0
- package/lib/helpers.js +20 -2
- package/lib/organizations.js +34 -50
- package/lib/people.js +24 -39
- package/lib/runtimeSecrets.js +44 -0
- package/package.json +15 -6
- package/test/config.js +1 -1
- package/test/index.js +2 -56
- package/test/merge-security.test.js +119 -0
- package/test/runtime-secrets.test.js +27 -0
- package/lib/changeBSON.js +0 -22
package/bin/bootstrap
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
|
|
4
|
+
var runtimeSecrets = require('../lib/runtimeSecrets');
|
|
5
|
+
|
|
6
|
+
runtimeSecrets.loadRuntimeSecrets()
|
|
7
|
+
.then(function() { require('../cli'); })
|
|
8
|
+
.catch(function(error) {
|
|
9
|
+
console.error('runtime_secret_bootstrap_failed', { code: error.name || error.code || 'UnknownError' });
|
|
10
|
+
process.exit(1);
|
|
11
|
+
});
|
package/config.js
CHANGED
|
@@ -8,12 +8,8 @@ module.exports = {
|
|
|
8
8
|
|
|
9
9
|
suppressDebugLog: true,
|
|
10
10
|
|
|
11
|
-
// db: {
|
|
12
|
-
// uri: 'mongodb://ffvc:ytnn5m7A9snIJSOLRqwo7Bpo2QkyYx6q@ds043952.mongolab.com:43952/ff-platform-test'
|
|
13
|
-
// },
|
|
14
|
-
|
|
15
11
|
db: {
|
|
16
|
-
uri:
|
|
12
|
+
uri: process.env.DB_URI || ''
|
|
17
13
|
},
|
|
18
14
|
|
|
19
15
|
crypto: {
|
package/index.js
CHANGED
|
@@ -23,12 +23,6 @@ var Merge = function(mongoose, config) {
|
|
|
23
23
|
this.Round = mongoose.model('Round');
|
|
24
24
|
this.Snapshot = mongoose.model('Snapshot');
|
|
25
25
|
|
|
26
|
-
// can choose to flag an entity as needing to be merged rather than actually merging it
|
|
27
|
-
// particularly useful for automated processes, e.g., a batch import that detects dupes
|
|
28
|
-
// but you don't want to auto-merge 1000 records
|
|
29
|
-
// defaults to false
|
|
30
|
-
this.flagOnly = config.flag == true ? true : false;
|
|
31
|
-
|
|
32
26
|
// turn on debug if in development and not explicitly turned off
|
|
33
27
|
mongoose.set('debug', (!config.suppressDebugLog && this.env == 'development'));
|
|
34
28
|
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const assert = require('node:assert/strict');
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
const vm = require('node:vm');
|
|
6
|
+
const { test } = require('node:test');
|
|
7
|
+
const root = path.resolve(__dirname, '../..');
|
|
8
|
+
const files = fs.existsSync(path.join(root, 'lib/runtimeSecrets.js'))
|
|
9
|
+
? ['lib/runtimeSecrets.js']
|
|
10
|
+
: fs.readdirSync(root).flatMap(dir => ['runtimeSecrets.js', 'runtime_secrets.js']
|
|
11
|
+
.map(name => path.join(dir, 'src/libs', name)).filter(file => fs.existsSync(path.join(root, file))));
|
|
12
|
+
assert(files.length > 0, 'No runtime loaders found');
|
|
13
|
+
|
|
14
|
+
function fixture(file, env = {}) {
|
|
15
|
+
const process = { env: { NODE_ENV: 'test', ...env } };
|
|
16
|
+
const module = { exports: {} };
|
|
17
|
+
const warnings = [];
|
|
18
|
+
class Client { constructor(options) { this.options = options; } }
|
|
19
|
+
class Command { constructor(input) { this.input = input; } }
|
|
20
|
+
const sdk = { SecretsManager: Client, SecretsManagerClient: Client, GetSecretValueCommand: Command };
|
|
21
|
+
vm.runInNewContext(fs.readFileSync(path.join(root, file), 'utf8'), {
|
|
22
|
+
module, exports: module.exports, process, Buffer,
|
|
23
|
+
console: { warn: (...args) => warnings.push(args), info: () => {} },
|
|
24
|
+
require: name => {
|
|
25
|
+
assert(['aws-sdk', '@aws-sdk/client-secrets-manager'].includes(name));
|
|
26
|
+
return sdk;
|
|
27
|
+
}
|
|
28
|
+
}, { filename: file });
|
|
29
|
+
const loader = module.exports;
|
|
30
|
+
const values = Object.fromEntries((loader.REQUIRED_KEYS || ['DB_URI']).map(key => [key, 'synthetic-' + key]));
|
|
31
|
+
const response = () => ({ SecretString: JSON.stringify(values) });
|
|
32
|
+
const client = fn => ({ send: fn, getSecretValue: () => ({ promise: fn }) });
|
|
33
|
+
return { loader, env: process.env, values, response, client, warnings };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
for (const file of files) {
|
|
37
|
+
test(file + ': explicit secret ID required in deployed runtimes', async () => {
|
|
38
|
+
for (const env of [
|
|
39
|
+
{ NODE_ENV: 'production', RUNTIME_SECRET_REQUIRED: 'false' },
|
|
40
|
+
{ AWS_LAMBDA_FUNCTION_NAME: 'synthetic-function', RUNTIME_SECRET_REQUIRED: 'false' },
|
|
41
|
+
{ RUNTIME_SECRET_REQUIRED: 'true' },
|
|
42
|
+
{ NODE_ENV: 'production', RUNTIME_SECRET_ID: ' ' }
|
|
43
|
+
]) {
|
|
44
|
+
const f = fixture(file, env);
|
|
45
|
+
let called = false;
|
|
46
|
+
await assert.rejects(f.loader.loadRuntimeSecrets({ client: f.client(async () => { called = true; }) }), /RUNTIME_SECRET_ID is required/);
|
|
47
|
+
assert.equal(called, false);
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
test(file + ': failed production fetch can recover without restarting', async () => {
|
|
51
|
+
const f = fixture(file, { NODE_ENV: 'production', RUNTIME_SECRET_ID: '/synthetic/secret', RUNTIME_SECRET_REQUIRED: 'false' });
|
|
52
|
+
let calls = 0;
|
|
53
|
+
const client = f.client(async () => { if (++calls === 1) throw new Error('synthetic transient failure'); return f.response(); });
|
|
54
|
+
await assert.rejects(f.loader.loadRuntimeSecrets({ client }), /synthetic transient failure/);
|
|
55
|
+
assert.equal((await f.loader.loadRuntimeSecrets({ client })).source, 'secrets_manager');
|
|
56
|
+
assert.equal(calls, 2);
|
|
57
|
+
assert.equal(f.warnings.length, 0);
|
|
58
|
+
});
|
|
59
|
+
test(file + ': concurrent calls share one fetch and successful values stay cached', async () => {
|
|
60
|
+
const f = fixture(file, { RUNTIME_SECRET_ID: '/synthetic/secret' });
|
|
61
|
+
let calls = 0, resolve;
|
|
62
|
+
const client = f.client(() => { calls++; return new Promise(done => { resolve = done; }); });
|
|
63
|
+
const first = f.loader.loadRuntimeSecrets({ client });
|
|
64
|
+
const second = f.loader.loadRuntimeSecrets({ client });
|
|
65
|
+
assert.equal(first, second);
|
|
66
|
+
resolve(f.response());
|
|
67
|
+
await Promise.all([first, second]);
|
|
68
|
+
await f.loader.loadRuntimeSecrets({ client });
|
|
69
|
+
assert.equal(calls, 1);
|
|
70
|
+
for (const key of Object.keys(f.values)) assert.equal(f.env[key], f.values[key]);
|
|
71
|
+
});
|
|
72
|
+
test(file + ': invalid secret never partially overwrites configuration and can retry', async () => {
|
|
73
|
+
const f = fixture(file, { NODE_ENV: 'production', RUNTIME_SECRET_ID: '/synthetic/secret' });
|
|
74
|
+
const keys = Object.keys(f.values);
|
|
75
|
+
for (const key of keys) f.env[key] = 'old-synthetic-value';
|
|
76
|
+
let calls = 0;
|
|
77
|
+
const incomplete = { ...f.values, [keys.at(-1)]: '' };
|
|
78
|
+
const client = f.client(async () => ++calls === 1 ? { SecretString: JSON.stringify(incomplete) } : f.response());
|
|
79
|
+
await assert.rejects(f.loader.loadRuntimeSecrets({ client }));
|
|
80
|
+
for (const key of keys) assert.equal(f.env[key], 'old-synthetic-value');
|
|
81
|
+
assert.equal((await f.loader.loadRuntimeSecrets({ client })).source, 'secrets_manager');
|
|
82
|
+
});
|
|
83
|
+
test(file + ': development fallback does not prevent a later successful fetch', async () => {
|
|
84
|
+
const f = fixture(file, { RUNTIME_SECRET_ID: '/synthetic/secret' });
|
|
85
|
+
let calls = 0;
|
|
86
|
+
const client = f.client(async () => { if (++calls === 1) throw new Error('synthetic denied'); return f.response(); });
|
|
87
|
+
try { await f.loader.loadRuntimeSecrets({ client }); } catch (error) { assert.match(error.message, /synthetic denied/); }
|
|
88
|
+
assert.equal((await f.loader.loadRuntimeSecrets({ client })).source, 'secrets_manager');
|
|
89
|
+
assert.equal(calls, 2);
|
|
90
|
+
});
|
|
91
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# Runtime secret loading and credential cleanup
|
|
2
|
+
|
|
3
|
+
Successful runtime secrets are loaded once, before configuration, clients, and
|
|
4
|
+
handlers initialize. Values remain cached until process replacement. Failed
|
|
5
|
+
fetches and development fallback results are not cached, so a later call can
|
|
6
|
+
retry; concurrent calls share one pending fetch. AWS SDK retries are bounded
|
|
7
|
+
(three attempts). Invalid secret contents never partially update configuration.
|
|
8
|
+
|
|
9
|
+
Production (`NODE_ENV=production`), deployed Lambda runtimes, and any runtime
|
|
10
|
+
with `RUNTIME_SECRET_REQUIRED=true` require an explicit, nonblank
|
|
11
|
+
`RUNTIME_SECRET_ID`. Setting the required flag to false cannot enable legacy
|
|
12
|
+
fallback in production or Lambda. Explicit development defaults remain unchanged.
|
|
13
|
+
|
|
14
|
+
See the [shared restart and cleanup runbook](https://github.com/Totem-VC/totem-services/blob/main/infrastructure/security-checks/runtime-secrets.md) for release verification, credential cleanup, and remaining limitations.
|
|
15
|
+
|
|
16
|
+
Run `node --test infrastructure/security-checks/runtime-secret-contract.cjs` for isolated loader checks. These source changes require the normal application release to affect running processes. No credential was rotated.
|
package/lib/helpers.js
CHANGED
|
@@ -43,6 +43,10 @@ var mergeObjects = function mergeObjects(target, source, doNotMergeKeys) {
|
|
|
43
43
|
|
|
44
44
|
for (var key in jsonSource) {
|
|
45
45
|
|
|
46
|
+
// Never follow prototype paths or merge inherited source properties.
|
|
47
|
+
// Check before reading target[key], which may resolve to a prototype.
|
|
48
|
+
if (!Object.prototype.hasOwnProperty.call(jsonSource, key)) continue;
|
|
49
|
+
if (key === '__proto__' || key === 'constructor' || key === 'prototype') continue;
|
|
46
50
|
if (doNotMergeKeys.indexOf(key) >= 0) continue;
|
|
47
51
|
|
|
48
52
|
var sourceItem = jsonSource[key];
|
|
@@ -70,12 +74,26 @@ var mergeObjects = function mergeObjects(target, source, doNotMergeKeys) {
|
|
|
70
74
|
|
|
71
75
|
};
|
|
72
76
|
|
|
73
|
-
var removeEntity = module.exports.removeEntity = function removeEntity(context, entity, cb) {
|
|
77
|
+
var removeEntity = module.exports.removeEntity = function removeEntity(context, modelName, entity, cb) {
|
|
74
78
|
|
|
75
79
|
context.logger.writeToInfoLog('Removing entity - ' + entity._id.toString());
|
|
76
80
|
|
|
77
|
-
entity
|
|
81
|
+
// entity may be a Mongoose document or a lean POJO (e.g. from Person.getById which uses .lean())
|
|
82
|
+
if (typeof entity.deleteOne === 'function') {
|
|
83
|
+
entity.deleteOne().then(function() { cb(null, entity); }).catch(cb);
|
|
84
|
+
}
|
|
85
|
+
else {
|
|
86
|
+
context[modelName].deleteOne({ _id: entity._id }).then(function() { cb(null, entity); }).catch(cb);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
};
|
|
78
90
|
|
|
91
|
+
// Add source id to target.merged, deduped. Works on POJOs and Mongoose docs alike.
|
|
92
|
+
var addMerged = module.exports.addMerged = function addMerged(target, sourceId) {
|
|
93
|
+
target.merged = target.merged || [];
|
|
94
|
+
var sourceIdStr = sourceId.toString();
|
|
95
|
+
var alreadyMerged = _.some(target.merged, function(m) { return m && m.toString() === sourceIdStr; });
|
|
96
|
+
if (!alreadyMerged) { target.merged = target.merged.concat([sourceId]); }
|
|
79
97
|
};
|
|
80
98
|
|
|
81
99
|
// Used for determining if need to dive in recursively
|
package/lib/organizations.js
CHANGED
|
@@ -32,70 +32,54 @@ var merge = function merge(context, target, source, mergedBy, customerId, cb) {
|
|
|
32
32
|
|
|
33
33
|
};
|
|
34
34
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
var text = 'This org should be merged with ' + url;
|
|
39
|
-
|
|
40
|
-
context.Organization.addFlag(target._id, target._id, text, customerId, function(err, result) {
|
|
41
|
-
if (err) return cb(err, null);
|
|
42
|
-
return cb(null, result);
|
|
43
|
-
});
|
|
44
|
-
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
else {
|
|
48
|
-
|
|
49
|
-
// mergedBy can be either an account or a username
|
|
50
|
-
var username = mergedBy.username ? mergedBy.username : mergedBy;
|
|
51
|
-
logger.writeToInfoLog('Username: ' + username);
|
|
52
|
-
|
|
53
|
-
async.series([
|
|
35
|
+
// mergedBy can be either an account or a username
|
|
36
|
+
var username = mergedBy.username ? mergedBy.username : mergedBy;
|
|
37
|
+
logger.writeToInfoLog('Username: ' + username);
|
|
54
38
|
|
|
55
|
-
|
|
39
|
+
async.series([
|
|
56
40
|
|
|
57
|
-
|
|
58
|
-
helpers.createSnapshot.bind(null, context, 'Organization', target, username),
|
|
41
|
+
verifyOkToMerge.bind(null, target, source),
|
|
59
42
|
|
|
60
|
-
|
|
61
|
-
|
|
43
|
+
// Create a snapshot of the target
|
|
44
|
+
helpers.createSnapshot.bind(null, context, 'Organization', target, username),
|
|
62
45
|
|
|
63
|
-
|
|
64
|
-
|
|
46
|
+
// Create a snapshot of the source
|
|
47
|
+
helpers.createSnapshot.bind(null, context, 'Organization', source, username),
|
|
65
48
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
helpers.removeEntity.bind(null, context, source),
|
|
49
|
+
// Merge the source into the target
|
|
50
|
+
mergeSourceIntoTarget.bind(null, target, source),
|
|
69
51
|
|
|
70
|
-
|
|
71
|
-
|
|
52
|
+
// Delete the source
|
|
53
|
+
// Do this prior to saving merged target in order to avoid unique key issues
|
|
54
|
+
helpers.removeEntity.bind(null, context, 'Organization', source),
|
|
72
55
|
|
|
73
|
-
|
|
74
|
-
|
|
56
|
+
// Save the updated target
|
|
57
|
+
saveOrganization.bind(null, target, username),
|
|
75
58
|
|
|
76
|
-
|
|
59
|
+
// Change source references to target
|
|
60
|
+
updateReferences.bind(null, context, target, source, mergedBy, customerId)
|
|
77
61
|
|
|
78
|
-
|
|
62
|
+
], function(err, results) {
|
|
79
63
|
|
|
80
|
-
|
|
81
|
-
var updatedTarget = results[5];
|
|
64
|
+
if (err) { return cb(err, null); }
|
|
82
65
|
|
|
83
|
-
|
|
84
|
-
|
|
66
|
+
// result from saveOrganization
|
|
67
|
+
var updatedTarget = results[5];
|
|
85
68
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
var targetMatch = targetIdStr && _.find(updatedReferencesOrgs, function(o) {
|
|
89
|
-
return o && o.id && o.id.toString() === targetIdStr;
|
|
90
|
-
});
|
|
91
|
-
|
|
92
|
-
// Return up to date target
|
|
93
|
-
if (targetMatch) { return cb(null, targetMatch); }
|
|
94
|
-
else { return cb(null, updatedTarget); }
|
|
69
|
+
// result from updateOrganizations within updateReferences
|
|
70
|
+
var updatedReferencesOrgs = results[6][5];
|
|
95
71
|
|
|
72
|
+
// Check if target had reference(s) updated
|
|
73
|
+
var targetIdStr = target.id && target.id.toString();
|
|
74
|
+
var targetMatch = targetIdStr && _.find(updatedReferencesOrgs, function(o) {
|
|
75
|
+
return o && o.id && o.id.toString() === targetIdStr;
|
|
96
76
|
});
|
|
97
77
|
|
|
98
|
-
|
|
78
|
+
// Return up to date target
|
|
79
|
+
if (targetMatch) { return cb(null, targetMatch); }
|
|
80
|
+
else { return cb(null, updatedTarget); }
|
|
81
|
+
|
|
82
|
+
});
|
|
99
83
|
|
|
100
84
|
};
|
|
101
85
|
|
|
@@ -113,7 +97,7 @@ var mergeSourceIntoTarget = function mergeEntities(target, source, cb) {
|
|
|
113
97
|
helpers.mergeObjects(target, source, doNotMergeKeys);
|
|
114
98
|
|
|
115
99
|
// keep a list of copied orgs
|
|
116
|
-
|
|
100
|
+
helpers.addMerged(target, source.id);
|
|
117
101
|
|
|
118
102
|
// add copied org name as an alias if it's different from source name
|
|
119
103
|
if (target.name.toLowerCase() != source.name.toLowerCase()) {
|
package/lib/people.js
CHANGED
|
@@ -28,56 +28,41 @@ var merge = function merge(context, target, source, mergedBy, customerId, cb) {
|
|
|
28
28
|
|
|
29
29
|
};
|
|
30
30
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
var url = 'https://admin.totemvc.com/people/' + source.id;
|
|
34
|
-
var text = 'This person should be merged with ' + url;
|
|
35
|
-
|
|
36
|
-
context.Person.addFlag(target._id, target._id, text, customerId, function(err, result) {
|
|
37
|
-
if (err) return cb(err, null);
|
|
38
|
-
return cb(null, result);
|
|
39
|
-
});
|
|
40
|
-
|
|
41
|
-
}
|
|
42
|
-
else {
|
|
43
|
-
|
|
44
|
-
// mergedBy can be either an account or a username
|
|
45
|
-
var username = mergedBy.username ? mergedBy.username : mergedBy;
|
|
31
|
+
// mergedBy can be either an account or a username
|
|
32
|
+
var username = mergedBy.username ? mergedBy.username : mergedBy;
|
|
46
33
|
|
|
47
|
-
|
|
34
|
+
async.series([
|
|
48
35
|
|
|
49
|
-
|
|
50
|
-
|
|
36
|
+
// Create a snapshot of the target
|
|
37
|
+
helpers.createSnapshot.bind(null, context, 'Person', target, username),
|
|
51
38
|
|
|
52
|
-
|
|
53
|
-
|
|
39
|
+
// Create a snapshot of the source
|
|
40
|
+
helpers.createSnapshot.bind(null, context, 'Person', source, username),
|
|
54
41
|
|
|
55
|
-
|
|
56
|
-
|
|
42
|
+
// Merge the source into the target
|
|
43
|
+
mergeSourceIntoTarget.bind(null, target, source),
|
|
57
44
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
45
|
+
// Delete the source
|
|
46
|
+
// Do this prior to saving merged target in order to avoid unique key issues
|
|
47
|
+
helpers.removeEntity.bind(null, context, 'Person', source),
|
|
61
48
|
|
|
62
|
-
|
|
63
|
-
|
|
49
|
+
// Save the updated target
|
|
50
|
+
savePerson.bind(null, target, username),
|
|
64
51
|
|
|
65
|
-
|
|
66
|
-
|
|
52
|
+
// Change source references to target
|
|
53
|
+
updateReferences.bind(null, context, target, source, mergedBy)
|
|
67
54
|
|
|
68
|
-
|
|
55
|
+
], function(err, results) {
|
|
69
56
|
|
|
70
|
-
|
|
57
|
+
if (err) return cb(err, null);
|
|
71
58
|
|
|
72
|
-
|
|
73
|
-
|
|
59
|
+
// result from savePerson
|
|
60
|
+
var updatedTarget = results[4];
|
|
74
61
|
|
|
75
|
-
|
|
76
|
-
|
|
62
|
+
// return updated target
|
|
63
|
+
return cb(null, updatedTarget);
|
|
77
64
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
}
|
|
65
|
+
});
|
|
81
66
|
|
|
82
67
|
};
|
|
83
68
|
|
|
@@ -121,7 +106,7 @@ var mergeSourceIntoTarget = function mergeEntities(target, source, cb) {
|
|
|
121
106
|
target.sources = mergeSources(target, source);
|
|
122
107
|
|
|
123
108
|
// keep a list of copied people
|
|
124
|
-
|
|
109
|
+
helpers.addMerged(target, source.id);
|
|
125
110
|
|
|
126
111
|
// add copied person name as an alias if it's different from source name
|
|
127
112
|
if (target.name.full.toLowerCase() != source.name.full.toLowerCase()) {
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
var SecretsManager = require('@aws-sdk/client-secrets-manager');
|
|
4
|
+
var SECRET_ID = '/totem/dev/totem-merge';
|
|
5
|
+
var loadPromise;
|
|
6
|
+
|
|
7
|
+
// Production and deployed Lambda runtimes must never use legacy credentials.
|
|
8
|
+
const secretsRequired = () => process.env.RUNTIME_SECRET_REQUIRED === 'true' ||
|
|
9
|
+
process.env.NODE_ENV === 'production' || Boolean(process.env.AWS_LAMBDA_FUNCTION_NAME);
|
|
10
|
+
|
|
11
|
+
function applySecret(response) {
|
|
12
|
+
var values = JSON.parse(response.SecretString || Buffer.from(response.SecretBinary).toString('utf8'));
|
|
13
|
+
if (!values.DB_URI || typeof values.DB_URI !== 'string') throw new Error('Runtime secret missing DB_URI');
|
|
14
|
+
process.env.DB_URI = values.DB_URI;
|
|
15
|
+
return { source: 'secrets_manager' };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async function load(client) {
|
|
19
|
+
var secretId = (process.env.RUNTIME_SECRET_ID || '').trim();
|
|
20
|
+
if (!secretId && secretsRequired()) throw new Error('RUNTIME_SECRET_ID is required for this runtime');
|
|
21
|
+
secretId = secretId || SECRET_ID;
|
|
22
|
+
return applySecret(await client.send(new SecretsManager.GetSecretValueCommand({ SecretId: secretId })));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function loadRuntimeSecrets(options) {
|
|
26
|
+
if (!loadPromise) {
|
|
27
|
+
const client = (options && options.client) || new SecretsManager.SecretsManagerClient({ region: process.env.AWS_REGION || 'us-east-1', maxAttempts: 3 });
|
|
28
|
+
// Cache successful loads until process replacement. Rejected/legacy loads
|
|
29
|
+
// are retried on the next call; concurrent callers share this attempt.
|
|
30
|
+
loadPromise = load(client).then(result => {
|
|
31
|
+
if (result.source !== 'secrets_manager') loadPromise = undefined;
|
|
32
|
+
return result;
|
|
33
|
+
}, error => {
|
|
34
|
+
loadPromise = undefined;
|
|
35
|
+
throw error;
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
return loadPromise;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function resetForTest() { loadPromise = undefined; }
|
|
42
|
+
|
|
43
|
+
module.exports = { SECRET_ID: SECRET_ID, applySecret: applySecret,
|
|
44
|
+
loadRuntimeSecrets: loadRuntimeSecrets, resetForTest: resetForTest };
|
package/package.json
CHANGED
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dhyasama/totem-merge",
|
|
3
|
-
"version": "7.
|
|
3
|
+
"version": "7.1.1",
|
|
4
4
|
"description": "Org and person merging package",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"scripts": {
|
|
7
|
-
"
|
|
8
|
-
"start": "NODE_ENV=test node ./cli",
|
|
7
|
+
"start": "node ./bin/bootstrap",
|
|
9
8
|
"test": "NODE_ENV=test mocha -R spec -t 10000"
|
|
10
9
|
},
|
|
11
10
|
"publishConfig": {
|
|
@@ -16,7 +15,8 @@
|
|
|
16
15
|
"node": ">=24 <25"
|
|
17
16
|
},
|
|
18
17
|
"dependencies": {
|
|
19
|
-
"@
|
|
18
|
+
"@aws-sdk/client-secrets-manager": "^3.883.0",
|
|
19
|
+
"@dhyasama/totem-logger": "3.0.0",
|
|
20
20
|
"@dhyasama/totem-utilities": "^4.0.0",
|
|
21
21
|
"async": "^2.6.4",
|
|
22
22
|
"commander": "^2.20.3",
|
|
@@ -24,12 +24,21 @@
|
|
|
24
24
|
"underscore": "^1.13.6"
|
|
25
25
|
},
|
|
26
26
|
"peerDependencies": {
|
|
27
|
-
"mongoose": "^8.
|
|
27
|
+
"mongoose": "^8.24.3"
|
|
28
28
|
},
|
|
29
29
|
"devDependencies": {
|
|
30
30
|
"@dhyasama/totem-models": "^12.14.0",
|
|
31
|
-
"mocha": "^
|
|
31
|
+
"mocha": "^12.0.2",
|
|
32
32
|
"mocha-mongoose": "^1.1.1",
|
|
33
33
|
"should": "^8.2.2"
|
|
34
|
+
},
|
|
35
|
+
"overrides": {
|
|
36
|
+
"r7insight_node": {
|
|
37
|
+
"lodash": "4.18.1",
|
|
38
|
+
"semver": "5.7.2",
|
|
39
|
+
"codependency": {
|
|
40
|
+
"semver": "5.7.2"
|
|
41
|
+
}
|
|
42
|
+
}
|
|
34
43
|
}
|
|
35
44
|
}
|
package/test/config.js
CHANGED
|
@@ -7,7 +7,7 @@ module.exports = {
|
|
|
7
7
|
LPS_ENABLED: false,
|
|
8
8
|
|
|
9
9
|
db: {
|
|
10
|
-
uri: 'mongodb://
|
|
10
|
+
uri: process.env.TEST_DB_URI || 'mongodb://127.0.0.1:27017/totem-merge-test'
|
|
11
11
|
},
|
|
12
12
|
|
|
13
13
|
crypto: {
|
package/test/index.js
CHANGED
|
@@ -2,11 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
var config = require('./config');
|
|
4
4
|
var dbUri = config.db.uri;
|
|
5
|
-
|
|
6
|
-
if (dbUri.indexOf('@test-shard-') === -1) {
|
|
7
|
-
console.log('Not a test db. Quitting...');
|
|
8
|
-
process.exit();
|
|
9
|
-
}
|
|
5
|
+
var describeMerge = process.env.TEST_DB_URI ? describe : describe.skip;
|
|
10
6
|
|
|
11
7
|
var should = require("should");
|
|
12
8
|
var mongoose = require('mongoose');
|
|
@@ -26,7 +22,7 @@ var customer;
|
|
|
26
22
|
var org1, org2, org3, org4, org5, org6;
|
|
27
23
|
var person1, person2, person3;
|
|
28
24
|
|
|
29
|
-
|
|
25
|
+
describeMerge('Merge', function() {
|
|
30
26
|
|
|
31
27
|
// connect to test db
|
|
32
28
|
before(async function() {
|
|
@@ -308,24 +304,8 @@ describe('Merge', function() {
|
|
|
308
304
|
|
|
309
305
|
describe('Orgs', function() {
|
|
310
306
|
|
|
311
|
-
it('Flags organization to be merged', function (done) {
|
|
312
|
-
|
|
313
|
-
config.flag = true;
|
|
314
|
-
merge = new Merge(mongoose, config);
|
|
315
|
-
|
|
316
|
-
merge.organizations(org2, org1, 'testy', customer._id, function(err, result) {
|
|
317
|
-
should.not.exist(err);
|
|
318
|
-
should.exist(result);
|
|
319
|
-
result.addedTo.flags.length.should.equal(1);
|
|
320
|
-
return done();
|
|
321
|
-
});
|
|
322
|
-
|
|
323
|
-
});
|
|
324
|
-
|
|
325
307
|
it('Merges organizations', function (done) {
|
|
326
308
|
|
|
327
|
-
// reset to not flag
|
|
328
|
-
config.flag = false;
|
|
329
309
|
merge = new Merge(mongoose, config);
|
|
330
310
|
|
|
331
311
|
Organization.upsert(org2, 'testy', function(err, result) {
|
|
@@ -362,8 +342,6 @@ describe('Merge', function() {
|
|
|
362
342
|
|
|
363
343
|
it('Merges organizations with different people', function (done) {
|
|
364
344
|
|
|
365
|
-
// reset to not flag
|
|
366
|
-
config.flag = false;
|
|
367
345
|
merge = new Merge(mongoose, config);
|
|
368
346
|
|
|
369
347
|
org4.people = [];
|
|
@@ -408,7 +386,6 @@ describe('Merge', function() {
|
|
|
408
386
|
|
|
409
387
|
it('Merges organizations with the same people', function (done) {
|
|
410
388
|
|
|
411
|
-
config.flag = false;
|
|
412
389
|
merge = new Merge(mongoose, config);
|
|
413
390
|
|
|
414
391
|
org2.people = [];
|
|
@@ -456,7 +433,6 @@ describe('Merge', function() {
|
|
|
456
433
|
|
|
457
434
|
it('Merges organizations when source has a deal', function (done) {
|
|
458
435
|
|
|
459
|
-
config.flag = false;
|
|
460
436
|
merge = new Merge(mongoose, config);
|
|
461
437
|
|
|
462
438
|
var deal = new Deal({
|
|
@@ -497,7 +473,6 @@ describe('Merge', function() {
|
|
|
497
473
|
|
|
498
474
|
it('Merges organizations when target has a list', function (done) {
|
|
499
475
|
|
|
500
|
-
config.flag = false;
|
|
501
476
|
merge = new Merge(mongoose, config);
|
|
502
477
|
|
|
503
478
|
var list = new List({
|
|
@@ -542,7 +517,6 @@ describe('Merge', function() {
|
|
|
542
517
|
|
|
543
518
|
it('Merges organizations when target has a message', function (done) {
|
|
544
519
|
|
|
545
|
-
config.flag = false;
|
|
546
520
|
merge = new Merge(mongoose, config);
|
|
547
521
|
|
|
548
522
|
var newOrg = new Organization();
|
|
@@ -599,7 +573,6 @@ describe('Merge', function() {
|
|
|
599
573
|
|
|
600
574
|
it('Merges organizations when target has news', function (done) {
|
|
601
575
|
|
|
602
|
-
config.flag = false;
|
|
603
576
|
merge = new Merge(mongoose, config);
|
|
604
577
|
|
|
605
578
|
var newOrg = new Organization();
|
|
@@ -650,7 +623,6 @@ describe('Merge', function() {
|
|
|
650
623
|
|
|
651
624
|
it('Will not merge customer', function (done) {
|
|
652
625
|
|
|
653
|
-
config.flag = false;
|
|
654
626
|
merge = new Merge(mongoose, config);
|
|
655
627
|
|
|
656
628
|
var newOrg = new Organization();
|
|
@@ -685,24 +657,8 @@ describe('Merge', function() {
|
|
|
685
657
|
|
|
686
658
|
describe('People', function() {
|
|
687
659
|
|
|
688
|
-
it('Flags person to be merged', function (done) {
|
|
689
|
-
|
|
690
|
-
config.flag = true;
|
|
691
|
-
merge = new Merge(mongoose, config);
|
|
692
|
-
|
|
693
|
-
merge.people(person2, person1, 'testy', customer._id, function(err, result) {
|
|
694
|
-
should.not.exist(err);
|
|
695
|
-
should.exist(result);
|
|
696
|
-
result.addedTo.flags.length.should.equal(1);
|
|
697
|
-
return done();
|
|
698
|
-
});
|
|
699
|
-
|
|
700
|
-
});
|
|
701
|
-
|
|
702
660
|
it('Merges people', function (done) {
|
|
703
661
|
|
|
704
|
-
// reset to not flag
|
|
705
|
-
config.flag = false;
|
|
706
662
|
merge = new Merge(mongoose, config);
|
|
707
663
|
|
|
708
664
|
merge.peopleById(person2.id, person1.id, 'testy', customer._id, function(err, result) {
|
|
@@ -723,8 +679,6 @@ describe('Merge', function() {
|
|
|
723
679
|
|
|
724
680
|
it('Merges people with different phone numbers', function (done) {
|
|
725
681
|
|
|
726
|
-
// reset to not flag
|
|
727
|
-
config.flag = false;
|
|
728
682
|
merge = new Merge(mongoose, config);
|
|
729
683
|
|
|
730
684
|
Person.getById(person2._id, { CUSTOMER_ID: customer._id }, function(err, result) {
|
|
@@ -831,8 +785,6 @@ describe('Merge', function() {
|
|
|
831
785
|
|
|
832
786
|
it('Merges two people that are referenced by same org', function (done) {
|
|
833
787
|
|
|
834
|
-
// reset to not flag
|
|
835
|
-
config.flag = false;
|
|
836
788
|
merge = new Merge(mongoose, config);
|
|
837
789
|
|
|
838
790
|
var myNewPerson1 = new Person();
|
|
@@ -955,8 +907,6 @@ describe('Merge', function() {
|
|
|
955
907
|
|
|
956
908
|
it('Merges two sources for same customer', function(done) {
|
|
957
909
|
|
|
958
|
-
// reset to not flag
|
|
959
|
-
config.flag = false;
|
|
960
910
|
merge = new Merge(mongoose, config);
|
|
961
911
|
|
|
962
912
|
var newPerson1 = new Person();
|
|
@@ -1013,8 +963,6 @@ describe('Merge', function() {
|
|
|
1013
963
|
|
|
1014
964
|
it('Merges same source for different customers', function(done) {
|
|
1015
965
|
|
|
1016
|
-
// reset to not flag
|
|
1017
|
-
config.flag = false;
|
|
1018
966
|
merge = new Merge(mongoose, config);
|
|
1019
967
|
|
|
1020
968
|
var newPerson1 = new Person();
|
|
@@ -1071,8 +1019,6 @@ describe('Merge', function() {
|
|
|
1071
1019
|
|
|
1072
1020
|
it('Merges two sources for two customers', function(done) {
|
|
1073
1021
|
|
|
1074
|
-
// reset to not flag
|
|
1075
|
-
config.flag = false;
|
|
1076
1022
|
merge = new Merge(mongoose, config);
|
|
1077
1023
|
|
|
1078
1024
|
var newPerson1 = new Person();
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var assert = require('node:assert/strict');
|
|
4
|
+
var mongoose = require('mongoose');
|
|
5
|
+
var mergeObjects = require('../lib/helpers').mergeObjects;
|
|
6
|
+
|
|
7
|
+
describe('mergeObjects security and compatibility', function() {
|
|
8
|
+
it('does not traverse __proto__ at the root or inside nested objects', function() {
|
|
9
|
+
[false, true].forEach(function(nested) {
|
|
10
|
+
var prototype = { mergeSecurityMarker: null };
|
|
11
|
+
var object = Object.create(prototype);
|
|
12
|
+
var payload = JSON.parse('{"__proto__":{"mergeSecurityMarker":"changed"}}');
|
|
13
|
+
mergeObjects(nested ? { profile: object } : object,
|
|
14
|
+
nested ? { profile: payload } : payload, []);
|
|
15
|
+
assert.equal(prototype.mergeSecurityMarker, null);
|
|
16
|
+
assert.equal(Object.getPrototypeOf(object), prototype);
|
|
17
|
+
});
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('does not write through Object.prototype', function() {
|
|
21
|
+
var key = '__totemMergeSecurityTest';
|
|
22
|
+
assert.equal(Object.hasOwn(Object.prototype, key), false);
|
|
23
|
+
Object.defineProperty(Object.prototype, key, {
|
|
24
|
+
value: null, writable: true, configurable: true
|
|
25
|
+
});
|
|
26
|
+
try {
|
|
27
|
+
mergeObjects({}, JSON.parse('{"__proto__":{"__totemMergeSecurityTest":true}}'), []);
|
|
28
|
+
assert.equal(Object.prototype[key], null);
|
|
29
|
+
assert.equal(({})[key], null);
|
|
30
|
+
} finally {
|
|
31
|
+
delete Object.prototype[key];
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('ignores constructor and prototype keys at each recursive level', function() {
|
|
36
|
+
var target = {
|
|
37
|
+
constructor: { prototype: { marker: null } },
|
|
38
|
+
prototype: { marker: null },
|
|
39
|
+
profile: { constructor: null, prototype: null, label: null }
|
|
40
|
+
};
|
|
41
|
+
var source = JSON.parse('{"constructor":{"prototype":{"marker":"bad"}},' +
|
|
42
|
+
'"prototype":{"marker":"bad"},' +
|
|
43
|
+
'"profile":{"constructor":"bad","prototype":"bad","label":"allowed"}}');
|
|
44
|
+
mergeObjects(target, source, []);
|
|
45
|
+
assert.equal(target.constructor.prototype.marker, null);
|
|
46
|
+
assert.equal(target.prototype.marker, null);
|
|
47
|
+
assert.deepEqual(target.profile, { constructor: null, prototype: null, label: 'allowed' });
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('ignores inherited source properties returned by a document conversion', function() {
|
|
51
|
+
var plain = Object.create({ inherited: 'not source data' });
|
|
52
|
+
plain.name = 'Allowed';
|
|
53
|
+
var target = { inherited: null, name: null };
|
|
54
|
+
mergeObjects(target, { toObject: function() { return plain; } }, []);
|
|
55
|
+
assert.deepEqual(target, { inherited: null, name: 'Allowed' });
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('preserves target precedence, exclusions, falsy values and source data', function() {
|
|
59
|
+
var target = {
|
|
60
|
+
name: 'Keep', description: null, enabled: false, count: 0,
|
|
61
|
+
empty: '', omitted: null, zero: null, disabled: null,
|
|
62
|
+
profile: { label: null, retained: 'Keep nested', omitted: null }
|
|
63
|
+
};
|
|
64
|
+
var source = {
|
|
65
|
+
name: 'Replace', description: 'Fill', enabled: true, count: 10,
|
|
66
|
+
empty: 'Replace', omitted: 'Excluded', zero: 0, disabled: false,
|
|
67
|
+
missing: 'Do not introduce',
|
|
68
|
+
profile: { label: 'Fill nested', retained: 'Replace', omitted: 'Excluded' }
|
|
69
|
+
};
|
|
70
|
+
var before = JSON.stringify(source);
|
|
71
|
+
assert.equal(mergeObjects(target, source, ['omitted']), target);
|
|
72
|
+
assert.deepEqual(target, {
|
|
73
|
+
name: 'Keep', description: 'Fill', enabled: false, count: 0,
|
|
74
|
+
empty: '', omitted: null, zero: null, disabled: null,
|
|
75
|
+
profile: { label: 'Fill nested', retained: 'Keep nested', omitted: null }
|
|
76
|
+
});
|
|
77
|
+
assert.equal(JSON.stringify(source), before);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('preserves array ordering, ObjectId deduplication and people/phone keys', function() {
|
|
81
|
+
var first = new mongoose.Types.ObjectId();
|
|
82
|
+
var second = new mongoose.Types.ObjectId();
|
|
83
|
+
var target = {
|
|
84
|
+
aliases: ['First'], refs: [first],
|
|
85
|
+
people: [{ person: first, title: 'Keep' }],
|
|
86
|
+
phone: [{ number: '(212) 555-0100', label: 'Keep' }]
|
|
87
|
+
};
|
|
88
|
+
mergeObjects(target, {
|
|
89
|
+
aliases: ['First', 'Second'], refs: [first, second],
|
|
90
|
+
people: [{ person: first, title: 'Replace' }, { person: second, title: 'New' }],
|
|
91
|
+
phone: [{ number: '2125550100', label: 'Replace' }, { number: '2125550101', label: 'New' }]
|
|
92
|
+
}, []);
|
|
93
|
+
assert.deepEqual(target.aliases, ['First', 'Second']);
|
|
94
|
+
assert.deepEqual(target.refs.map(String), [String(first), String(second)]);
|
|
95
|
+
assert.equal(target.people.length, 2);
|
|
96
|
+
assert.equal(target.people[0].title, 'Keep');
|
|
97
|
+
assert.equal(target.people[1].title, 'New');
|
|
98
|
+
assert.equal(target.phone.length, 2);
|
|
99
|
+
assert.equal(target.phone[0].label, 'Keep');
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('merges real Mongoose documents without invoking virtual getters', function() {
|
|
103
|
+
var isolated = new mongoose.Mongoose();
|
|
104
|
+
var schema = new isolated.Schema({
|
|
105
|
+
name: String, description: { type: String, default: null }, aliases: [String],
|
|
106
|
+
profile: { label: { type: String, default: null } }
|
|
107
|
+
});
|
|
108
|
+
schema.virtual('unsafeVirtual').get(function() { throw new Error('Virtual invoked'); });
|
|
109
|
+
var Model = isolated.model('MergeSecurityFixture', schema);
|
|
110
|
+
var target = new Model({ name: 'Keep', aliases: ['First'] });
|
|
111
|
+
var source = new Model({ name: 'Replace', description: 'Fill', aliases: ['First', 'Second'], profile: { label: 'Nested' } });
|
|
112
|
+
mergeObjects(target, source, ['_id', '__v']);
|
|
113
|
+
assert.equal(target.name, 'Keep');
|
|
114
|
+
assert.equal(target.description, 'Fill');
|
|
115
|
+
assert.deepEqual(Array.from(target.aliases), ['First', 'Second']);
|
|
116
|
+
assert.equal(target.profile.label, 'Nested');
|
|
117
|
+
assert.equal(source.name, 'Replace');
|
|
118
|
+
});
|
|
119
|
+
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
var assert = require('node:assert/strict');
|
|
4
|
+
var fs = require('node:fs');
|
|
5
|
+
var path = require('node:path');
|
|
6
|
+
var runtimeSecrets = require('../lib/runtimeSecrets');
|
|
7
|
+
|
|
8
|
+
describe('totem-merge runtime secrets', function() {
|
|
9
|
+
afterEach(function() { runtimeSecrets.resetForTest(); });
|
|
10
|
+
|
|
11
|
+
it('loads DB_URI from the dedicated development secret', async function() {
|
|
12
|
+
var client = { send: async function(command) {
|
|
13
|
+
assert.equal(command.input.SecretId, runtimeSecrets.SECRET_ID);
|
|
14
|
+
return { SecretString: JSON.stringify({ DB_URI: 'mongodb://test-host/test' }) };
|
|
15
|
+
} };
|
|
16
|
+
delete process.env.RUNTIME_SECRET_ID;
|
|
17
|
+
await runtimeSecrets.loadRuntimeSecrets({ client: client });
|
|
18
|
+
assert.equal(process.env.DB_URI, 'mongodb://test-host/test');
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('contains no credentialed MongoDB URI in source configuration', function() {
|
|
22
|
+
var root = path.join(__dirname, '..');
|
|
23
|
+
['config.js', 'test/config.js'].forEach(function(file) {
|
|
24
|
+
assert.doesNotMatch(fs.readFileSync(path.join(root, file), 'utf8'), /mongodb(?:\+srv)?:\/\/[^\s]+:[^\s]+@/);
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
});
|
package/lib/changeBSON.js
DELETED
|
@@ -1,22 +0,0 @@
|
|
|
1
|
-
"use strict";
|
|
2
|
-
|
|
3
|
-
var fs = require('fs');
|
|
4
|
-
var filePath = './node_modules/mongoose-post-find/node_modules/bson/ext/index.js';
|
|
5
|
-
var config = require('./../config');
|
|
6
|
-
var Logger = require('@dhyasama/totem-logger');
|
|
7
|
-
var logger = new Logger(config.logentries);
|
|
8
|
-
|
|
9
|
-
fs.readFile(filePath, 'utf8', function (err, data) {
|
|
10
|
-
|
|
11
|
-
if (err) { logger.handleErr(err); }
|
|
12
|
-
else {
|
|
13
|
-
|
|
14
|
-
var result = data.replace("bson = require('../build/Release/bson');", "bson = require('bson');");
|
|
15
|
-
|
|
16
|
-
fs.writeFile(filePath, result, 'utf8', function (err) {
|
|
17
|
-
if (err) { logger.handleErr(err); }
|
|
18
|
-
});
|
|
19
|
-
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
});
|