Skip to content

Commit

Permalink
feat(#6289): Bumps CouchDb version to 3.3.2 (#8107)
Browse files Browse the repository at this point in the history
User roles are added to db security on settings change.
Config remains unchanged with a couple of exceptions:
- request_timeout no longer infinity because of this error
- added [attachments] section back, as cht-conf depends on those settings to exist and it isn't part of default config anymore

#6289
  • Loading branch information
dianabarsan authored Sep 12, 2023
1 parent 6ff0641 commit 1251fd0
Show file tree
Hide file tree
Showing 16 changed files with 406 additions and 43 deletions.
38 changes: 38 additions & 0 deletions api/src/db.js
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ if (UNIT_TEST_ENV) {
'saveDocs',
'createVault',
'wipeCacheDb',
'addRoleAsAdmin',
'addRoleAsMember',
];

const notStubbed = (first, second) => {
Expand Down Expand Up @@ -178,4 +180,40 @@ if (UNIT_TEST_ENV) {

throw new Error(`Error while saving docs: ${errors.join(', ')}`);
};

const getDefaultSecurityStructure = () => ({
names: [],
roles: [],
});

const addRoleToSecurity = async (dbname, role, addAsAdmin) => {
if (!dbname || !role) {
throw new Error(`Cannot add security: invalid db name ${dbname} or role ${role}`);
}

const securityUrl = new URL(environment.serverUrl);
securityUrl.pathname = `${dbname}/_security`;

const securityObject = await rpn.get({ url: securityUrl.toString(), json: true });
const property = addAsAdmin ? 'admins' : 'members';

if (!securityObject[property]) {
securityObject[property] = getDefaultSecurityStructure();
}

if (!securityObject[property].roles || !Array.isArray(securityObject[property].roles)) {
securityObject[property].roles = [];
}

if (securityObject[property].roles.includes(role)) {
return;
}

logger.info(`Adding "${role}" role to ${dbname} ${property}`);
securityObject[property].roles.push(role);
await rpn.put({ url: securityUrl.toString(), json: true, body: securityObject });
};

module.exports.addRoleAsAdmin = (dbname, role) => addRoleToSecurity(dbname, role, true);
module.exports.addRoleAsMember = (dbname, role) => addRoleToSecurity(dbname, role, false);
}
15 changes: 15 additions & 0 deletions api/src/services/config-watcher.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ const generateXform = require('./generate-xform');
const generateServiceWorker = require('../generate-service-worker');
const manifest = require('./manifest');
const config = require('../config');
const environment = require('../environment');
const extensionLibs = require('./extension-libs');

const MEDIC_DDOC_ID = '_design/medic';
Expand Down Expand Up @@ -92,6 +93,7 @@ const handleSettingsChange = () => {
logger.error('Failed to reload settings: %o', err);
process.exit(1);
})
.then(() => addUserRolesToDb())
.then(() => initTransitionLib())
.then(() => {
configUpdatesEvents.emit('updated');
Expand Down Expand Up @@ -143,6 +145,7 @@ const load = () => {
loadViewMaps();
return loadTranslations()
.then(() => loadSettings())
.then(() => addUserRolesToDb())
.then(() => initTransitionLib())
.then(() => db.createVault());
};
Expand Down Expand Up @@ -184,10 +187,22 @@ const watch = (callback) => {
configUpdatesEvents.on('updated', callback);
};

const addUserRolesToDb = async () => {
const roles = config.get('roles');
if (!roles || typeof roles !== 'object') {
return;
}

for (const role of Object.keys(roles)) {
await db.addRoleAsMember(environment.db, role);
}
};

module.exports = {
load,
listen,
updateServiceWorker,
loadTranslations,
watch,
addUserRolesToDb,
};
171 changes: 171 additions & 0 deletions api/tests/mocha/db.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -233,4 +233,175 @@ describe('db', () => {
expect(db.close.args).to.deep.equal([[dbObject]]);
});
});

describe('addRoleToSecurity', () => {
it('should add role as member to db security', async () => {
sinon.stub(env, 'serverUrl').get(() => 'http://admin:pass@couchdb:5984');
sinon.stub(rpn, 'get').resolves({ admins: { roles: ['role1'] }, members: { roles: ['role2'] } });
sinon.stub(rpn, 'put').resolves();

await db.addRoleAsMember('dbname', 'rolename');

expect(rpn.get.args).to.deep.equal([[ { url: 'http://admin:pass@couchdb:5984/dbname/_security', json: true } ]]);
expect(rpn.put.args).to.deep.equal([[
{
url: 'http://admin:pass@couchdb:5984/dbname/_security',
json: true,
body: {
admins: { roles: ['role1'] },
members: { roles: ['role2', 'rolename'] },
}
}
]]);
});

it('should add role as admin to db security', async () => {
sinon.stub(env, 'serverUrl').get(() => 'http://admin:pass@couchdb:5984');
sinon.stub(rpn, 'get').resolves({ admins: { roles: ['role1'] }, members: { roles: ['role2'] } });
sinon.stub(rpn, 'put').resolves();

await db.addRoleAsAdmin('dbname', 'rolename');

expect(rpn.get.args).to.deep.equal([[ { url: 'http://admin:pass@couchdb:5984/dbname/_security', json: true } ]]);
expect(rpn.put.args).to.deep.equal([[
{
url: 'http://admin:pass@couchdb:5984/dbname/_security',
json: true,
body: {
admins: { roles: ['role1', 'rolename'], },
members: { roles: ['role2'] },
}
}
]]);
});

it('should skip member roles that already exist', async () => {
sinon.stub(env, 'serverUrl').get(() => 'http://admin:pass@couchdb:5984');
sinon.stub(rpn, 'get').resolves({ admins: { roles: ['role1'] }, members: { roles: ['role2'] } });
sinon.stub(rpn, 'put').resolves();

await db.addRoleAsMember('dbname', 'role2');

expect(rpn.get.args).to.deep.equal([[ { url: 'http://admin:pass@couchdb:5984/dbname/_security', json: true } ]]);
expect(rpn.put.called).to.equal(false);
});

it('should skip admin roles that already exist', async () => {
sinon.stub(env, 'serverUrl').get(() => 'http://admin:pass@couchdb:5984');
sinon.stub(rpn, 'get').resolves({ admins: { roles: ['role1'] }, members: { roles: ['role2'] } });
sinon.stub(rpn, 'put').resolves();

await db.addRoleAsAdmin('dbname', 'role1');

expect(rpn.get.args).to.deep.equal([[ { url: 'http://admin:pass@couchdb:5984/dbname/_security', json: true } ]]);
expect(rpn.put.called).to.equal(false);
});

it('should set members security property if not existing', async () => {
sinon.stub(env, 'serverUrl').get(() => 'http://admin:pass@couchdb:5984');
sinon.stub(rpn, 'get').resolves({ admins: { roles: ['role1'] } });
sinon.stub(rpn, 'put').resolves();

await db.addRoleAsMember('dbname', 'rolename');

expect(rpn.get.args).to.deep.equal([[ { url: 'http://admin:pass@couchdb:5984/dbname/_security', json: true } ]]);
expect(rpn.put.args).to.deep.equal([[
{
url: 'http://admin:pass@couchdb:5984/dbname/_security',
json: true,
body: {
admins: { roles: ['role1'] },
members: { roles: ['rolename'], names: [], },
}
}
]]);
});

it('should not mutate default roles', async () => {
sinon.stub(env, 'serverUrl').get(() => 'http://admin:pass@couchdb:5984');
sinon.stub(rpn, 'get').callsFake(() => ({ members: {} }));
sinon.stub(rpn, 'put').resolves();

await db.addRoleAsMember('dbname1', 'rolename1');
await db.addRoleAsMember('dbname2', 'rolename2');

expect(rpn.put.args).to.deep.equal([[
{
url: 'http://admin:pass@couchdb:5984/dbname1/_security',
json: true,
body: {
members: { roles: ['rolename1'] },
}
}
], [
{
url: 'http://admin:pass@couchdb:5984/dbname2/_security',
json: true,
body: {
members: { roles: ['rolename2'] },
}
}
]]);
});

it('should set admins security property if not existing', async () => {
sinon.stub(env, 'serverUrl').get(() => 'http://admin:pwd@host:6984');
sinon.stub(rpn, 'get').resolves({ members: { roles: ['role2'] } });
sinon.stub(rpn, 'put').resolves();

await db.addRoleAsAdmin('name', 'arole');

expect(rpn.get.args).to.deep.equal([[ { url: 'http://admin:pwd@host:6984/name/_security', json: true } ]]);
expect(rpn.put.args).to.deep.equal([[
{
url: 'http://admin:pwd@host:6984/name/_security',
json: true,
body: {
admins: { roles: ['arole'], names: [] },
members: { roles: ['role2'] },
}
}
]]);
});

it('should throw get security errors', async () => {
sinon.stub(env, 'serverUrl').get(() => 'http://admin:pass@couchdb:5984');
sinon.stub(rpn, 'get').rejects(new Error('not_found'));

await expect(db.addRoleAsMember('data', 'attr')).to.be.rejectedWith(Error, 'not_found');
});

it('should throw put security errors', async () => {
sinon.stub(env, 'serverUrl').get(() => 'http://admin:pass@couchdb:5984');
sinon.stub(rpn, 'get').resolves({ members: { roles: ['role2'] } });
sinon.stub(rpn, 'put').rejects(new Error('forbidden or something'));

await expect(db.addRoleAsMember('data', 'attr')).to.be.rejectedWith(Error, 'forbidden or something');
});

it('should set default security when security is invalid', async () => {
sinon.stub(env, 'serverUrl').get(() => 'http://admin:pass@couchdb:5984');
sinon.stub(rpn, 'get').resolves({ members: false });
sinon.stub(rpn, 'put').resolves();

await db.addRoleAsMember('data', 'attr');
expect(rpn.put.args).to.deep.equal([[
{
url: 'http://admin:pass@couchdb:5984/data/_security',
json: true,
body: {
members: { roles: ['attr'], names: [], },
}
}
]]);

});

it('should throw when missing dbname or role', async () => {
await expect(db.addRoleAsMember('', 'arole'))
.to.be.rejectedWith(Error, `Cannot add security: invalid db name or role arole`);
await expect(db.addRoleAsMember('dbanme', ''))
.to.be.rejectedWith(Error, `Cannot add security: invalid db name dbanme or role`);
});
});
});
35 changes: 35 additions & 0 deletions api/tests/mocha/services/config-watcher.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ const generateXform = require('../../../src/services/generate-xform');
const config = require('../../../src/config');
const bootstrap = require('../../../src/services/config-watcher');
const manifest = require('../../../src/services/manifest');
const environment = require('../../../src/environment');

describe('Configuration', () => {
beforeEach(() => {
Expand Down Expand Up @@ -217,13 +218,47 @@ describe('Configuration', () => {
settingsService.get.resolves({ settings: 'yes' });
const listener = sinon.stub();
bootstrap.watch(listener);
sinon.stub(db, 'addRoleAsMember');
sinon.stub(config, 'get').withArgs('roles').returns({ chw: {} });
sinon.stub(environment, 'db').get(() => 'medicdb');

return dbWatcher.medic.args[0][0]({ id: 'settings' }).then(() => {
chai.expect(settingsService.update.callCount).to.equal(1);
chai.expect(settingsService.get.callCount).to.equal(1);
chai.expect(config.set.callCount).to.equal(1);
chai.expect(config.set.args[0]).to.deep.equal([{ settings: 'yes' }]);
chai.expect(listener.callCount).to.equal(1);
chai.expect(config.get.withArgs('roles').callCount).to.equal(1);
chai.expect(db.addRoleAsMember.args).to.deep.equal([['medicdb', 'chw']]);
});
});

it('should add all configured user roles to the main database', () => {
settingsService.update.resolves();
settingsService.get.resolves({ settings: 'yes' });
sinon.stub(db, 'addRoleAsMember');
sinon.stub(config, 'get')
.withArgs('roles')
.returns({
chw1: {},
chw2: {},
chw3: {},
chw4: {},
});
sinon.stub(environment, 'db').get(() => 'medicdb');

return dbWatcher.medic.args[0][0]({ id: 'settings' }).then(() => {
chai.expect(settingsService.update.callCount).to.equal(1);
chai.expect(settingsService.get.callCount).to.equal(1);
chai.expect(config.set.callCount).to.equal(1);
chai.expect(config.set.args[0]).to.deep.equal([{ settings: 'yes' }]);
chai.expect(config.get.withArgs('roles').callCount).to.equal(1);
chai.expect(db.addRoleAsMember.args).to.deep.equal([
['medicdb', 'chw1'],
['medicdb', 'chw2'],
['medicdb', 'chw3'],
['medicdb', 'chw4'],
]);
});
});

Expand Down
10 changes: 6 additions & 4 deletions couchdb/10-docker-default.ini
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
; couchdb/local.d/package.ini

[fabric]
request_timeout = infinity
request_timeout = 31536000 ; 1 year in seconds

[query_server_config]
os_process_limit = 1000
Expand All @@ -20,14 +20,12 @@ socket_options = [{sndbuf, 262144}, {nodelay, true}]
require_valid_user = true

[httpd]
port = 5986
bind_address = 0.0.0.0
secure_rewrites = false
max_http_request_size = 4294967296 ; 4 GB
WWW-Authenticate = Basic realm="Medic Mobile Web Services"

[couch_httpd_auth]
# timeout is set to 1 year in seconds, 31536000
; timeout is set to 1 year in seconds, 31536000
timeout = 31536000
allow_persistent_cookies = true
require_valid_user = true
Expand All @@ -39,3 +37,7 @@ ssl_certificate_max_depth = 4
[cluster]
q=12
n=1

[attachments]
compressible_types = text/*, application/javascript, application/json, application/xml
compression_level = 8
2 changes: 1 addition & 1 deletion couchdb/Dockerfile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
FROM couchdb:2.3.1 as base_couchdb_build
FROM couchdb:3.3.2 as base_couchdb_build

# Add configuration
COPY --chown=couchdb:couchdb 10-docker-default.ini /opt/couchdb/etc/default.d/
Expand Down
15 changes: 10 additions & 5 deletions couchdb/set-up-cluster.sh
Original file line number Diff line number Diff line change
Expand Up @@ -91,20 +91,25 @@ add_peers_to_cluster() {
check_cluster_membership
}

create_system_databases() {
for db in _users _replicator _global_changes; do
if ! curl -sfX PUT "http://$COUCHDB_USER:$COUCHDB_PASSWORD@$SVC_NAME:5984/$db" > /dev/null; then
echo "Failed to create system database '$db'"
fi
done
}

main(){
check_if_couchdb_is_ready http://$COUCHDB_USER:$COUCHDB_PASSWORD@$SVC_NAME:5984
# only attempt clustering if CLUSTER_PEER_IPS environment variable is present.
if [ ! -z "$CLUSTER_PEER_IPS" ]; then
enable_cluster
add_peers_to_cluster
create_system_databases
fi
# only attempt to setup initial databases if single node is used
if [ -z "$CLUSTER_PEER_IPS" ] && [ -z "$COUCHDB_SYNC_ADMINS_NODE" ]; then
for db in _users _replicator _global_changes; do
if ! curl -sfX PUT "http://$COUCHDB_USER:$COUCHDB_PASSWORD@$SVC_NAME:5984/$db" > /dev/null; then
echo "Failed to create system database '$db'"
fi
done
create_system_databases
fi
# end process
exit 1
Expand Down
Loading

0 comments on commit 1251fd0

Please sign in to comment.