diff --git a/packages/composer-cli/lib/cmds/identity/bindCommand.js b/packages/composer-cli/lib/cmds/identity/bindCommand.js new file mode 100644 index 0000000000..9ff7b29a08 --- /dev/null +++ b/packages/composer-cli/lib/cmds/identity/bindCommand.js @@ -0,0 +1,40 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +const Bind = require ('./lib/bind.js'); + +module.exports.command = 'bind [options]'; +module.exports.describe = 'Bind an existing identity to a participant in a participant registry'; +module.exports.builder = { + connectionProfileName: {alias: 'p', required: false, describe: 'The connection profile name', type: 'string' }, + businessNetworkName: {alias: 'n', required: true, describe: 'The business network name', type: 'string' }, + enrollId: { alias: 'i', required: true, describe: 'The enrollment ID of the user', type: 'string' }, + enrollSecret: { alias: 's', required: false, describe: 'The enrollment secret of the user', type: 'string' }, + participantId: { alias: 'a', required: true, describe: 'The particpant to issue the new identity to', type: 'string' }, + publicKeyFile: { alias: 'c', required: true, describe: 'File containing the public key', type: 'string' } +}; + +module.exports.handler = (argv) => { + + argv.thePromise = Bind.handler(argv) + .then(() => { + return; + }) + .catch((error) => { + throw error; + }); + return argv.thePromise; +}; diff --git a/packages/composer-cli/lib/cmds/identity/issueCommand.js b/packages/composer-cli/lib/cmds/identity/issueCommand.js index 181e09d590..f36a32bdb5 100644 --- a/packages/composer-cli/lib/cmds/identity/issueCommand.js +++ b/packages/composer-cli/lib/cmds/identity/issueCommand.js @@ -17,7 +17,7 @@ const Issue = require ('./lib/issue.js'); module.exports.command = 'issue [options]'; -module.exports.describe = 'Issue an identity to a participant in a participant registry'; +module.exports.describe = 'Issue a new identity to a participant in a participant registry'; module.exports.builder = { connectionProfileName: {alias: 'p', required: false, describe: 'The connection profile name', type: 'string' }, businessNetworkName: {alias: 'n', required: true, describe: 'The business network name', type: 'string' }, diff --git a/packages/composer-cli/lib/cmds/identity/lib/bind.js b/packages/composer-cli/lib/cmds/identity/lib/bind.js new file mode 100644 index 0000000000..701453b511 --- /dev/null +++ b/packages/composer-cli/lib/cmds/identity/lib/bind.js @@ -0,0 +1,93 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +const cmdUtil = require('../../utils/cmdutils'); +const DEFAULT_PROFILE_NAME = 'defaultProfile'; +const fs = require('fs'); + +/** + *

+ * Composer "identity bind" command + *

+ *

+ * @private + */ +class Bind { + + /** + * Command process for deploy command + * @param {string} argv argument list from composer command + * @return {Promise} promise when command complete + */ + static handler(argv) { + let businessNetworkConnection; + let enrollId; + let enrollSecret; + let connectionProfileName = Bind.getDefaultProfileName(argv); + let businessNetworkName; + let participantId = argv.participantId; + let publicKeyFile = argv.publicKeyFile; + let certificate; + try { + certificate = fs.readFileSync(publicKeyFile).toString(); + } catch(error) { + return Promise.reject(new Error('Unable to read public key file ' + publicKeyFile + '. ' + error.message)); + } + + return (() => { + if (!argv.enrollSecret) { + return cmdUtil.prompt({ + name: 'enrollmentSecret', + description: 'What is the enrollment secret of the user?', + required: true, + hidden: true, + replace: '*' + }) + .then((result) => { + argv.enrollSecret = result; + }); + } else { + return Promise.resolve(); + } + })() + .then(() => { + enrollId = argv.enrollId; + enrollSecret = argv.enrollSecret; + businessNetworkName = argv.businessNetworkName; + businessNetworkConnection = cmdUtil.createBusinessNetworkConnection(); + return businessNetworkConnection.connect(connectionProfileName, businessNetworkName, enrollId, enrollSecret); + }) + .then(() => { + return businessNetworkConnection.bindIdentity(participantId, certificate); + }) + .then((result) => { + console.log(`An identity was bound to the participant '${participantId}'`); + console.log('The participant can now connect to the business network using the identity'); + }); + } + + /** + * Get default profile name + * @param {argv} argv program arguments + * @return {String} defaultConnection profile name + */ + static getDefaultProfileName(argv) { + return argv.connectionProfileName || DEFAULT_PROFILE_NAME; + } + +} + +module.exports = Bind; diff --git a/packages/composer-cli/lib/cmds/identity/revokeCommand.js b/packages/composer-cli/lib/cmds/identity/revokeCommand.js index 6fdc8e9559..aa9239640d 100644 --- a/packages/composer-cli/lib/cmds/identity/revokeCommand.js +++ b/packages/composer-cli/lib/cmds/identity/revokeCommand.js @@ -17,7 +17,7 @@ const Revoke = require ('./lib/revoke.js'); module.exports.command = 'revoke [options]'; -module.exports.describe = 'Revoke an identity that was issued to a participant'; +module.exports.describe = 'Revoke an identity that was issued or bound to a participant'; module.exports.builder = { connectionProfileName: {alias: 'p', required: false, describe: 'The connection profile name', type: 'string' }, businessNetworkName: {alias: 'n', required: true, describe: 'The business network name', type: 'string' }, diff --git a/packages/composer-cli/package.json b/packages/composer-cli/package.json index 60efd18aa8..816736169c 100644 --- a/packages/composer-cli/package.json +++ b/packages/composer-cli/package.json @@ -83,9 +83,9 @@ ], "all": true, "check-coverage": true, - "statements": 60, - "branches": 50, - "functions": 58, - "lines": 60 + "statements": 70, + "branches": 56, + "functions": 62, + "lines": 70 } } diff --git a/packages/composer-cli/test/identity/bind.js b/packages/composer-cli/test/identity/bind.js new file mode 100644 index 0000000000..15641a0ed9 --- /dev/null +++ b/packages/composer-cli/test/identity/bind.js @@ -0,0 +1,138 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +const Client = require('composer-client'); +const BusinessNetworkConnection = Client.BusinessNetworkConnection; + +const Bind = require('../../lib/cmds/identity/bindCommand.js'); +const CmdUtil = require('../../lib/cmds/utils/cmdutils.js'); + +const sinon = require('sinon'); +require('sinon-as-promised'); +const chai = require('chai'); +chai.should(); +chai.use(require('chai-as-promised')); + +const fs = require('fs'); + +const BUSINESS_NETWORK_NAME = 'net.biz.TestNetwork-0.0.1'; +const DEFAULT_PROFILE_NAME = 'defaultProfile'; +const ENROLL_ID = 'SuccessKid'; +const ENROLL_SECRET = 'SuccessKidWin'; + +describe('composer identity bind CLI unit tests', () => { + + const pem = '-----BEGIN CERTIFICATE-----\nMIIB8TCCAZegAwIBAgIUKhzgF0nmP1Zl6uubdgq6wFohMC8wCgYIKoZIzj0EAwIw\nczELMAkGA1UEBhMCVVMxEzARBgNVBAgTCkNhbGlmb3JuaWExFjAUBgNVBAcTDVNh\nbiBGcmFuY2lzY28xGTAXBgNVBAoTEG9yZzEuZXhhbXBsZS5jb20xHDAaBgNVBAMT\nE2NhLm9yZzEuZXhhbXBsZS5jb20wHhcNMTcwNzEyMjIzNzAwWhcNMTgwNzEyMjIz\nNzAwWjAQMQ4wDAYDVQQDEwVhZG1pbjBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IA\nBPnG/X+v7R2ljdtRoreLnub9vlvU9BmF7LTuklTC0iHJr96ecQRkx6OCE8nDK09G\n5P6LvL95PUxlhDDdlStqASGjbDBqMA4GA1UdDwEB/wQEAwIHgDAMBgNVHRMBAf8E\nAjAAMB0GA1UdDgQWBBQl3QT+2qX5eTM2zaJ+MjU4vF+NQDArBgNVHSMEJDAigCAZ\nq2WruwSAfa0S5MCpqqZknnCGjjq9AhejItieR+GmrjAKBggqhkjOPQQDAgNIADBF\nAiEAok4RzwsOX7lSkUmGK+yfr9reJxvtyCHxQ68YC7blAQECIB3T3H0iwX+2MZaX\nmJucq33qkeHpFiq1focn3o6WAx/x\n-----END CERTIFICATE-----\n'; + + let sandbox; + let mockBusinessNetworkConnection; + + beforeEach(() => { + sandbox = sinon.sandbox.create(); + mockBusinessNetworkConnection = sinon.createStubInstance(BusinessNetworkConnection); + mockBusinessNetworkConnection.connect.resolves(); + mockBusinessNetworkConnection.bindIdentity.withArgs('org.doge.Doge#DOGE_1', pem, sinon.match.object).resolves({ + userID: 'dogeid1', + userSecret: 'suchsecret' + }); + sandbox.stub(fs, 'readFileSync').withArgs('admin.pem').returns(pem); + sandbox.stub(CmdUtil, 'createBusinessNetworkConnection').returns(mockBusinessNetworkConnection); + sandbox.stub(process, 'exit'); + }); + + afterEach(() => { + sandbox.restore(); + }); + + it('should bind an existing identity using the default profile', () => { + let argv = { + businessNetworkName: BUSINESS_NETWORK_NAME, + enrollId: ENROLL_ID, + enrollSecret: ENROLL_SECRET, + participantId: 'org.doge.Doge#DOGE_1', + publicKeyFile: 'admin.pem' + }; + return Bind.handler(argv) + .then((res) => { + sinon.assert.calledOnce(mockBusinessNetworkConnection.connect); + sinon.assert.calledWith(mockBusinessNetworkConnection.connect, DEFAULT_PROFILE_NAME, argv.businessNetworkName, argv.enrollId, argv.enrollSecret); + sinon.assert.calledOnce(mockBusinessNetworkConnection.bindIdentity); + sinon.assert.calledWith(mockBusinessNetworkConnection.bindIdentity, 'org.doge.Doge#DOGE_1', pem); + }); + }); + + it('should bind an existing identity using the specified profile', () => { + let argv = { + connectionProfileName: 'someOtherProfile', + businessNetworkName: BUSINESS_NETWORK_NAME, + enrollId: ENROLL_ID, + enrollSecret: ENROLL_SECRET, + participantId: 'org.doge.Doge#DOGE_1', + publicKeyFile: 'admin.pem' + }; + return Bind.handler(argv) + .then((res) => { + sinon.assert.calledOnce(mockBusinessNetworkConnection.connect); + sinon.assert.calledWith(mockBusinessNetworkConnection.connect, 'someOtherProfile', argv.businessNetworkName, argv.enrollId, argv.enrollSecret); + sinon.assert.calledOnce(mockBusinessNetworkConnection.bindIdentity); + sinon.assert.calledWith(mockBusinessNetworkConnection.bindIdentity, 'org.doge.Doge#DOGE_1', pem); + }); + }); + + it('should prompt for the enrollment secret if not specified', () => { + sandbox.stub(CmdUtil, 'prompt').resolves(ENROLL_SECRET); + let argv = { + businessNetworkName: BUSINESS_NETWORK_NAME, + enrollId: ENROLL_ID, + participantId: 'org.doge.Doge#DOGE_1', + publicKeyFile: 'admin.pem' + }; + return Bind.handler(argv) + .then((res) => { + sinon.assert.calledOnce(mockBusinessNetworkConnection.connect); + sinon.assert.calledWith(mockBusinessNetworkConnection.connect, DEFAULT_PROFILE_NAME, argv.businessNetworkName, argv.enrollId, argv.enrollSecret); + sinon.assert.calledOnce(mockBusinessNetworkConnection.bindIdentity); + sinon.assert.calledWith(mockBusinessNetworkConnection.bindIdentity, 'org.doge.Doge#DOGE_1', pem); + }); + }); + + it('should error when the certificate file cannot be read', () => { + fs.readFileSync.withArgs('admin.pem').throws(new Error('such error')); + let argv = { + businessNetworkName: BUSINESS_NETWORK_NAME, + enrollId: ENROLL_ID, + enrollSecret: ENROLL_SECRET, + participantId: 'org.doge.Doge#DOGE_1', + publicKeyFile: 'admin.pem' + }; + return Bind.handler(argv) + .should.be.rejectedWith(/such error/); + }); + + it('should error when the existing identity cannot be bound', () => { + mockBusinessNetworkConnection.bindIdentity.withArgs('org.doge.Doge#DOGE_1', pem).rejects(new Error('such error')); + let argv = { + businessNetworkName: BUSINESS_NETWORK_NAME, + enrollId: ENROLL_ID, + enrollSecret: ENROLL_SECRET, + participantId: 'org.doge.Doge#DOGE_1', + publicKeyFile: 'admin.pem' + }; + return Bind.handler(argv) + .should.be.rejectedWith(/such error/); + }); + +}); diff --git a/packages/composer-runtime/lib/context.js b/packages/composer-runtime/lib/context.js index 08f238dc09..397dfca1dc 100644 --- a/packages/composer-runtime/lib/context.js +++ b/packages/composer-runtime/lib/context.js @@ -321,10 +321,12 @@ class Context { // Check for an admin user. // TODO: this is temporary whilst we migrate to requiring all // users to have identities that are mapped to participants. - const name = this.getIdentityService().getName(); - if (name && name.match(/admin/i)) { - LOG.exit(method, null); - return null; + if (!error.activationRequired) { + const name = this.getIdentityService().getName(); + if (name && name.match(/admin/i)) { + LOG.exit(method, null); + return null; + } } // Throw the error. diff --git a/packages/composer-runtime/test/context.js b/packages/composer-runtime/test/context.js index ac19778530..ba8df7e850 100644 --- a/packages/composer-runtime/test/context.js +++ b/packages/composer-runtime/test/context.js @@ -336,6 +336,24 @@ describe('Context', () => { .should.be.rejectedWith(/such error/); }); + it('should not ignore an activation required error from looking up the identity for admin users', () => { + mockIdentityManager.getIdentity.resolves(mockIdentity); + mockIdentityManager.getParticipant.withArgs(mockIdentity).resolves(mockParticipant); + context.function = 'bindIdentity'; + const error = new Error('such error'); + error.activationRequired = true; + mockIdentityManager.validateIdentity.withArgs(mockIdentity).throws(error); + let promise = Promise.resolve(); + ['admin', 'Admin', 'WebAppAdmin'].forEach((admin) => { + mockIdentityService.getName.returns(admin); + promise = promise.then(() => { + return context.loadCurrentParticipant() + .should.be.rejectedWith(/such error/); + }); + }); + return promise; + }); + it('should ignore any errors from looking up the identity for admin users', () => { let promise = Promise.resolve(); ['admin', 'Admin', 'WebAppAdmin'].forEach((admin) => {