Skip to content

Commit d323eab

Browse files
authored
feat: allow to save connection info COMPASS-4630 (#2510)
* wip: preserve connection info on save COMPASS-4630 * use CommaAndColonSeparatedRecord * add convertConnectionInfoToModel test * more conversion tests + fix lastUsed * add replicaSet name test * add read preference tags tests * add scram conversion tests * add tests for X509 and convert SSL properties * remove only * test ssl * test convert id * ssh options * encrypt tls cert password * fix AWS * fix ts types
1 parent ccdd334 commit d323eab

File tree

7 files changed

+1077
-222
lines changed

7 files changed

+1077
-222
lines changed

packages/connection-model/lib/extended-model.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ const ExtendedConnection = Connection.extend(storageMixin, {
3232
namespace: 'Connections',
3333
basepath,
3434
appName, // Not to be confused with `props.appname` that is being sent to driver
35-
secureCondition: (val, key) => key.match(/(password|passphrase)/i)
35+
secureCondition: (val, key) => key.match(/(password|passphrase|secrets)/i)
3636
},
3737
props: {
3838
_id: { type: 'string', default: () => uuidv4() },

packages/connection-model/lib/model.js

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,16 @@ const session = {};
6565

6666
let Connection = {};
6767

68+
/**
69+
* New ConnectionInfo properties
70+
*/
71+
Object.assign(props, {
72+
connectionInfo: { type: 'object', default: undefined },
73+
74+
// anything in secrets is saved in the keyring by storage mixin
75+
secrets: { type: 'object', default: undefined }
76+
});
77+
6878
/**
6979
* Assigning observable top-level properties of a state class.
7080
*/
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
import { expect } from 'chai';
2+
import { ConnectionInfo } from './connection-info';
3+
import {
4+
mergeSecrets,
5+
extractSecrets,
6+
ConnectionSecrets,
7+
} from './connection-secrets';
8+
9+
describe('connection secrets', function () {
10+
describe('mergeSecrets', function () {
11+
it('does not modify the original object', function () {
12+
const originalConnectionInfo: ConnectionInfo = {
13+
connectionOptions: {
14+
connectionString: 'mongodb://localhost:27017',
15+
sshTunnel: {
16+
host: 'localhost',
17+
username: 'user',
18+
port: 22,
19+
},
20+
},
21+
favorite: {
22+
name: 'connection 1',
23+
},
24+
};
25+
26+
const originalConnectionInfoStr = JSON.stringify(originalConnectionInfo);
27+
28+
const newConnectionInfo = mergeSecrets(originalConnectionInfo, {
29+
password: 'xxx',
30+
awsSessionToken: 'xxx',
31+
sshTunnelPassphrase: 'xxx',
32+
tlsCertificateKeyFilePassword: 'xxx',
33+
});
34+
35+
expect(newConnectionInfo).to.not.equal(originalConnectionInfo);
36+
37+
expect(newConnectionInfo.connectionOptions).to.not.equal(
38+
originalConnectionInfo.connectionOptions
39+
);
40+
41+
expect(newConnectionInfo.connectionOptions.sshTunnel).to.not.equal(
42+
originalConnectionInfo.connectionOptions.sshTunnel
43+
);
44+
45+
expect(newConnectionInfo.favorite).to.not.equal(
46+
originalConnectionInfo.favorite
47+
);
48+
49+
expect(originalConnectionInfoStr).to.equal(
50+
JSON.stringify(originalConnectionInfo)
51+
);
52+
});
53+
54+
it('merges secrets', function () {
55+
const originalConnectionInfo: ConnectionInfo = {
56+
connectionOptions: {
57+
connectionString: 'mongodb://username@localhost:27017/',
58+
sshTunnel: {
59+
host: 'localhost',
60+
username: 'user',
61+
port: 22,
62+
},
63+
},
64+
};
65+
66+
const newConnectionInfo = mergeSecrets(originalConnectionInfo, {
67+
awsSessionToken: 'sessionToken',
68+
password: 'userPassword',
69+
sshTunnelPassphrase: 'passphrase',
70+
tlsCertificateKeyFilePassword: 'tlsCertPassword',
71+
});
72+
73+
expect(newConnectionInfo).to.be.deep.equal({
74+
connectionOptions: {
75+
connectionString:
76+
'mongodb://username:userPassword@localhost:27017/?tlsCertificateKeyFilePassword=tlsCertPassword&authMechanismProperties=AWS_SESSION_TOKEN%3AsessionToken',
77+
sshTunnel: {
78+
host: 'localhost',
79+
username: 'user',
80+
port: 22,
81+
identityKeyPassphrase: 'passphrase',
82+
},
83+
},
84+
} as ConnectionInfo);
85+
});
86+
});
87+
88+
describe('extractSecrets', function () {
89+
it('does not modify the original object', function () {
90+
const originalConnectionInfo: ConnectionInfo = {
91+
connectionOptions: {
92+
connectionString: 'mongodb://localhost:27017',
93+
sshTunnel: {
94+
host: 'localhost',
95+
username: 'user',
96+
port: 22,
97+
},
98+
},
99+
favorite: {
100+
name: 'connection 1',
101+
},
102+
};
103+
104+
const originalConnectionInfoStr = JSON.stringify(originalConnectionInfo);
105+
106+
const { connectionInfo: newConnectionInfo } = extractSecrets(
107+
originalConnectionInfo
108+
);
109+
110+
expect(newConnectionInfo).to.not.equal(originalConnectionInfo);
111+
112+
expect(newConnectionInfo.connectionOptions).to.not.equal(
113+
originalConnectionInfo.connectionOptions
114+
);
115+
116+
expect(newConnectionInfo.connectionOptions.sshTunnel).to.not.equal(
117+
originalConnectionInfo.connectionOptions.sshTunnel
118+
);
119+
120+
expect(newConnectionInfo.favorite).to.not.equal(
121+
originalConnectionInfo.favorite
122+
);
123+
124+
expect(originalConnectionInfoStr).to.equal(
125+
JSON.stringify(originalConnectionInfo)
126+
);
127+
});
128+
129+
it('extracts secrets', function () {
130+
const originalConnectionInfo: ConnectionInfo = {
131+
connectionOptions: {
132+
connectionString:
133+
'mongodb://username:userPassword@localhost:27017/?tlsCertificateKeyFilePassword=tlsCertPassword&authMechanismProperties=AWS_SESSION_TOKEN%3AsessionToken',
134+
sshTunnel: {
135+
host: 'localhost',
136+
username: 'user',
137+
port: 22,
138+
identityKeyPassphrase: 'passphrase',
139+
},
140+
},
141+
};
142+
143+
const { connectionInfo: newConnectionInfo, secrets } = extractSecrets(
144+
originalConnectionInfo
145+
);
146+
147+
expect(newConnectionInfo).to.be.deep.equal({
148+
connectionOptions: {
149+
connectionString: 'mongodb://username@localhost:27017/',
150+
sshTunnel: {
151+
host: 'localhost',
152+
username: 'user',
153+
port: 22,
154+
},
155+
},
156+
} as ConnectionInfo);
157+
158+
expect(secrets).to.be.deep.equal({
159+
awsSessionToken: 'sessionToken',
160+
password: 'userPassword',
161+
sshTunnelPassphrase: 'passphrase',
162+
tlsCertificateKeyFilePassword: 'tlsCertPassword',
163+
} as ConnectionSecrets);
164+
});
165+
});
166+
});
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import _ from 'lodash';
2+
import ConnectionString, {
3+
CommaAndColonSeparatedRecord,
4+
} from 'mongodb-connection-string-url';
5+
import { ConnectionInfo } from './connection-info';
6+
7+
export interface ConnectionSecrets {
8+
password?: string;
9+
sshTunnelPassphrase?: string;
10+
awsSessionToken?: string;
11+
tlsCertificateKeyFilePassword?: string;
12+
}
13+
14+
const AWS_SESSION_TOKEN_PROPERTY = 'AWS_SESSION_TOKEN';
15+
const AUTH_MECHANISM_PROPERTIES_PARAM = 'authMechanismProperties';
16+
const TLS_CERTIFICATE_KEY_FILE_PASSWORD_PARAM = 'tlsCertificateKeyFilePassword';
17+
18+
export function mergeSecrets(
19+
connectionInfo: Readonly<ConnectionInfo>,
20+
secrets: ConnectionSecrets | undefined
21+
): ConnectionInfo {
22+
const connectionInfoWithSecrets = _.cloneDeep(connectionInfo);
23+
24+
if (!secrets) {
25+
return connectionInfoWithSecrets;
26+
}
27+
28+
const connectionOptions = connectionInfoWithSecrets.connectionOptions;
29+
30+
const uri = new ConnectionString(connectionOptions.connectionString);
31+
32+
if (secrets.password) {
33+
uri.password = secrets.password;
34+
}
35+
36+
if (secrets.sshTunnelPassphrase && connectionOptions.sshTunnel) {
37+
connectionOptions.sshTunnel.identityKeyPassphrase =
38+
secrets.sshTunnelPassphrase;
39+
}
40+
41+
if (secrets.tlsCertificateKeyFilePassword) {
42+
uri.searchParams.set(
43+
TLS_CERTIFICATE_KEY_FILE_PASSWORD_PARAM,
44+
secrets.tlsCertificateKeyFilePassword
45+
);
46+
}
47+
48+
if (secrets.awsSessionToken) {
49+
const authMechanismProperties = new CommaAndColonSeparatedRecord(
50+
uri.searchParams.get(AUTH_MECHANISM_PROPERTIES_PARAM)
51+
);
52+
53+
authMechanismProperties.set(
54+
AWS_SESSION_TOKEN_PROPERTY,
55+
secrets.awsSessionToken
56+
);
57+
58+
uri.searchParams.set(
59+
AUTH_MECHANISM_PROPERTIES_PARAM,
60+
authMechanismProperties.toString()
61+
);
62+
}
63+
64+
connectionInfoWithSecrets.connectionOptions.connectionString = uri.href;
65+
66+
return connectionInfoWithSecrets;
67+
}
68+
69+
export function extractSecrets(connectionInfo: Readonly<ConnectionInfo>): {
70+
connectionInfo: ConnectionInfo;
71+
secrets: ConnectionSecrets;
72+
} {
73+
const connectionInfoWithoutSecrets = _.cloneDeep(connectionInfo);
74+
const secrets: ConnectionSecrets = {};
75+
76+
const connectionOptions = connectionInfoWithoutSecrets.connectionOptions;
77+
const uri = new ConnectionString(connectionOptions.connectionString);
78+
79+
if (uri.password) {
80+
secrets.password = uri.password;
81+
uri.password = '';
82+
}
83+
84+
if (connectionOptions.sshTunnel?.identityKeyPassphrase) {
85+
secrets.sshTunnelPassphrase =
86+
connectionOptions.sshTunnel.identityKeyPassphrase;
87+
delete connectionOptions.sshTunnel.identityKeyPassphrase;
88+
}
89+
90+
if (uri.searchParams.has(TLS_CERTIFICATE_KEY_FILE_PASSWORD_PARAM)) {
91+
secrets.tlsCertificateKeyFilePassword =
92+
uri.searchParams.get(TLS_CERTIFICATE_KEY_FILE_PASSWORD_PARAM) ||
93+
undefined;
94+
uri.searchParams.delete(TLS_CERTIFICATE_KEY_FILE_PASSWORD_PARAM);
95+
}
96+
97+
const authMechanismProperties = new CommaAndColonSeparatedRecord(
98+
uri.searchParams.get(AUTH_MECHANISM_PROPERTIES_PARAM)
99+
);
100+
101+
if (authMechanismProperties.has(AWS_SESSION_TOKEN_PROPERTY)) {
102+
secrets.awsSessionToken = authMechanismProperties.get(
103+
AWS_SESSION_TOKEN_PROPERTY
104+
);
105+
authMechanismProperties.delete(AWS_SESSION_TOKEN_PROPERTY);
106+
107+
if (authMechanismProperties.toString()) {
108+
uri.searchParams.set(
109+
AUTH_MECHANISM_PROPERTIES_PARAM,
110+
authMechanismProperties.toString()
111+
);
112+
} else {
113+
uri.searchParams.delete(AUTH_MECHANISM_PROPERTIES_PARAM);
114+
}
115+
}
116+
117+
connectionInfoWithoutSecrets.connectionOptions.connectionString = uri.href;
118+
119+
return { connectionInfo: connectionInfoWithoutSecrets, secrets };
120+
}

packages/data-service/src/connection-storage.spec.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import { TestBackend } from 'storage-mixin';
1+
// eslint-disable-next-line @typescript-eslint/no-var-requires
2+
const { TestBackend } = require('storage-mixin');
23

34
import { expect } from 'chai';
45

@@ -95,6 +96,19 @@ describe('ConnectionStorage', function () {
9596
},
9697
]);
9798
});
99+
100+
it('should convert lastUsed', async function () {
101+
const id = uuid();
102+
const lastUsed = new Date('2021-10-26T13:51:27.585Z');
103+
writeFakeConnection(tmpDir, {
104+
_id: id,
105+
lastUsed,
106+
});
107+
108+
const connectionStorage = new ConnectionStorage();
109+
const connections = await connectionStorage.loadAll();
110+
expect(connections[0].lastUsed).to.deep.equal(lastUsed);
111+
});
98112
});
99113

100114
describe('save', function () {

0 commit comments

Comments
 (0)