diff --git a/lib/pubsub/iam.js b/lib/pubsub/iam.js new file mode 100644 index 000000000000..67c1b76171f0 --- /dev/null +++ b/lib/pubsub/iam.js @@ -0,0 +1,235 @@ +/*! + * Copyright 2014 Google Inc. All Rights Reserved. + * + * 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. + */ + +/*! + * @module pubsub/iam + */ + +'use strict'; + +var is = require('is'); +var arrify = require('arrify'); + +/*! Developer Documentation + * + * @param {module:pubsub} pubsub - PubSub Object + * @param {string} resource - topic or subscription name + */ +/** + * [IAM (Identity and Access Management)](https://cloud.google.com/pubsub/access_control) + * allows you to set permissions on invidual resources and offers a wider range + * of roles: editor, owner, publisher, subscriber, and viewer. This gives you + * greater flexibility and allows you to set more fine-grained access control. + * + * For example: + * * Grant access on a per-topic or per-subscription basis, rather than for + * the whole Cloud project. + * * Grant access with limited capabilities, such as to only publish messages + * to a topic, or to only to consume messages from a subscription, but not + * to delete the topic or subscription. + * + * + * *The IAM access control features described in this document are Beta, + * including the API methods to get and set IAM policies, and to test IAM + * permissions. Google Cloud Pub/Sub's use of IAM features is not covered by any + * SLA or deprecation policy, and may be subject to backward-incompatible + * changes.* + * + * @constructor + * @alias module:pubsub/iam + * + * @resource [Access Control Overview]{@link https://cloud.google.com/pubsub/access_control} + * @resource [What is Cloud IAM?]{@link https://cloud.google.com/iam/} + * + * @example + * var pubsub = gcloud.pubsub({ + * projectId: 'grape-spaceship-123', + * keyFilename: '/path/to/keyfile.json' + * }); + * + * var topic = pubsub.topic('my-topic'); + * // topic.iam + * + * var subscription = pubsub.subscription('my-subscription'); + * // subscription.iam + */ +function IAM(pubsub, resource) { + this.resource = resource; + this.makeReq_ = pubsub.makeReq_.bind(pubsub); +} + +/** + * Get the IAM policy + * + * @param {function} callback - The callback function. + * @param {?error} callback.err - An error returned while making this request. + * @param {object} callback.policy - The [policy](https://cloud.google.com/pubsub/reference/rest/Shared.Types/Policy). + * @param {object} callback.apiResponse - The full API response. + * + * @alias iam.getPolicy + * + * @resource [Topics: getIamPolicy API Documentation]{@link https://cloud.google.com/pubsub/reference/rest/v1/projects.topics/getIamPolicy} + * @resource [Subscriptions: getIamPolicy API Documentation]{@link https://cloud.google.com/pubsub/reference/rest/v1/projects.subscriptions/getIamPolicy} + * + * @example + * topic.iam.getPolicy(function(err, policy, apiResponse) {}); + * + * subscription.iam.getPolicy(function(err, policy, apiResponse) {}); + */ +IAM.prototype.getPolicy = function(callback) { + var path = this.resource + ':getIamPolicy'; + + this.makeReq_('GET', path, null, null, function(err, resp) { + if (err) { + callback(err, null, resp); + return; + } + + callback(null, resp, resp); + }); +}; + +/** + * Set the IAM policy + * + * @throws {Error} If no policy is provided. + * + * @param {object} policy - The [policy](https://cloud.google.com/pubsub/reference/rest/Shared.Types/Policy). + * @param {array=} policy.bindings - Bindings associate members with roles. + * @param {object[]=} policy.rules - Rules to be applied to the policy. + * @param {string=} policy.etag - Etags are used to perform a read-modify-write. + * @param {function} callback - The callback function. + * @param {?error} callback.err - An error returned while making this request. + * @param {object} callback.policy - The updated policy. + * @param {object} callback.apiResponse - The full API response. + * + * @alias iam.setPolicy + * + * @resource [Topics: setIamPolicy API Documentation]{@link https://cloud.google.com/pubsub/reference/rest/v1/projects.topics/setIamPolicy} + * @resource [Subscriptions: setIamPolicy API Documentation]{@link https://cloud.google.com/pubsub/reference/rest/v1/projects.subscriptions/setIamPolicy} + * @resource [Policy]{@link https://cloud.google.com/pubsub/reference/rest/Shared.Types/Policy} + * + * @example + * var myPolicy = { + * bindings: [ + * { + * role: 'roles/pubsub.subscriber', + * members: ['serviceAccount:myotherproject@appspot.gserviceaccount.com'] + * } + * ] + * }; + * + * topic.iam.setPolicy(myPolicy, function(err, policy, apiResponse) {}); + * + * subscription.iam.setPolicy(myPolicy, function(err, policy, apiResponse) {}); + */ +IAM.prototype.setPolicy = function(policy, callback) { + if (!is.object(policy)) { + throw new Error('A policy is required'); + } + + var path = this.resource + ':setIamPolicy'; + var body = { + policy: policy + }; + + this.makeReq_('POST', path, null, body, function(err, resp) { + if (err) { + callback(err, null, resp); + return; + } + + callback(null, resp, resp); + }); +}; + +/** + * Test a set of permissions for a resource. + * + * Permissions with wildcards such as `*` or `storage.*` are not allowed. + * + * @throws {Error} If permissions are not provided. + * + * @param {string|string[]} permissions - The permission(s) to test for. + * @param {function} callback - The callback function. + * @param {?error} callback.err - An error returned while making this request. + * @param {array} callback.permissions - A subset of permissions that the caller + * is allowed + * @param {object} callback.apiResponse - The full API response. + * + * @alias iam.testPermissions + * + * @resource [Topics: testIamPermissions API Documentation]{@link https://cloud.google.com/pubsub/reference/rest/v1/projects.topics/testIamPermissions} + * @resource [Subscriptions: testIamPermissions API Documentation]{@link https://cloud.google.com/pubsub/reference/rest/v1/projects.subscriptions/testIamPermissions} + * @resource [Permissions Reference]{@link https://cloud.google.com/pubsub/access_control#permissions} + * + * @example + * //- + * // Test a single permission. + * //- + * var test = 'pubsub.topics.update'; + * + * topic.iam.testPermissions(test, function(err, permissions, apiResponse) { + * console.log(permissions); + * // { + * // "pubsub.topics.update": true + * // } + * }); + * + * //- + * // Test several permissions at once. + * //- + * var tests = [ + * 'pubsub.subscriptions.consume', + * 'pubsub.subscriptions.update' + * ]; + * + * subscription.iam.testPermissions(tests, function(err, permissions) { + * console.log(permissions); + * // { + * // "pubsub.subscriptions.consume": true, + * // "pubsub.subscriptions.update": false + * // } + * }); + */ +IAM.prototype.testPermissions = function(permissions, callback) { + if (!is.array(permissions) && !is.string(permissions)) { + throw new Error('Permissions are required'); + } + + var path = this.resource + ':testIamPermissions'; + var body = { + permissions: arrify(permissions) + }; + + this.makeReq_('POST', path, null, body, function(err, resp) { + if (err) { + callback(err, null, resp); + return; + } + + var availablePermissions = resp.permissions || []; + + var permissionsHash = body.permissions.reduce(function(acc, permission) { + acc[permission] = availablePermissions.indexOf(permission) > -1; + return acc; + }, {}); + + callback(null, permissionsHash, resp); + }); +}; + +module.exports = IAM; diff --git a/lib/pubsub/subscription.js b/lib/pubsub/subscription.js index 822f8ada0017..4ff223f6514b 100644 --- a/lib/pubsub/subscription.js +++ b/lib/pubsub/subscription.js @@ -31,6 +31,12 @@ var nodeutil = require('util'); */ var util = require('../common/util.js'); +/** + * @type {module:pubsub/iam} + * @private + */ +var IAM = require('./iam'); + /*! Developer Documentation * * @param {module:pubsub} pubsub - PubSub object. @@ -144,6 +150,34 @@ function Subscription(pubsub, options) { this.messageListeners = 0; this.paused = false; + /** + * [IAM (Identity and Access Management)](https://cloud.google.com/pubsub/access_control) + * allows you to set permissions on invidual resources and offers a wider + * range of roles: editor, owner, publisher, subscriber, and viewer. This + * gives you greater flexibility and allows you to set more fine-grained + * access control. + * + * *The IAM access control features described in this document are Beta, + * including the API methods to get and set IAM policies, and to test IAM + * permissions. Google Cloud Pub/Sub's use of IAM features is not covered by + * any SLA or deprecation policy, and may be subject to backward-incompatible + * changes.* + * + * @mixes module:pubsub/iam + * + * @resource [Access Control Overview]{@link https://cloud.google.com/pubsub/access_control} + * @resource [What is Cloud IAM?]{@link https://cloud.google.com/iam/} + * + * @example + * //- + * // Get the IAM policy for your subscription. + * //- + * subscription.iam.getPolicy(function(err, policy) { + * console.log(policy); + * }); + */ + this.iam = new IAM(pubsub, this.name); + this.listenForEvents_(); } diff --git a/lib/pubsub/topic.js b/lib/pubsub/topic.js index 0723a2e1d198..20df59b5123b 100644 --- a/lib/pubsub/topic.js +++ b/lib/pubsub/topic.js @@ -30,6 +30,12 @@ var prop = require('propprop'); */ var util = require('../common/util.js'); +/** + * @type {module:pubsub/iam} + * @private + */ +var IAM = require('./iam'); + /*! Developer Documentation * * @param {module:pubsub} pubsub - PubSub object. @@ -56,6 +62,34 @@ function Topic(pubsub, name) { this.unformattedName = name; this.makeReq_ = this.pubsub.makeReq_.bind(this.pubsub); + + /** + * [IAM (Identity and Access Management)](https://cloud.google.com/pubsub/access_control) + * allows you to set permissions on invidual resources and offers a wider + * range of roles: editor, owner, publisher, subscriber, and viewer. This + * gives you greater flexibility and allows you to set more fine-grained + * access control. + * + * *The IAM access control features described in this document are Beta, + * including the API methods to get and set IAM policies, and to test IAM + * permissions. Google Cloud Pub/Sub's use of IAM features is not covered by + * any SLA or deprecation policy, and may be subject to backward-incompatible + * changes.* + * + * @mixes module:pubsub/iam + * + * @resource [Access Control Overview]{@link https://cloud.google.com/pubsub/access_control} + * @resource [What is Cloud IAM?]{@link https://cloud.google.com/iam/} + * + * @example + * //- + * // Get the IAM policy for your topic. + * //- + * topic.iam.getPolicy(function(err, policy) { + * console.log(policy); + * }); + */ + this.iam = new IAM(pubsub, this.name); } /** diff --git a/scripts/docs.sh b/scripts/docs.sh index 49020e714d45..9fa6e898d50c 100755 --- a/scripts/docs.sh +++ b/scripts/docs.sh @@ -35,6 +35,7 @@ ./node_modules/.bin/dox < lib/pubsub/index.js > docs/json/master/pubsub/index.json & ./node_modules/.bin/dox < lib/pubsub/subscription.js > docs/json/master/pubsub/subscription.json & ./node_modules/.bin/dox < lib/pubsub/topic.js > docs/json/master/pubsub/topic.json & +./node_modules/.bin/dox < lib/pubsub/iam.js > docs/json/master/pubsub/iam.json & ./node_modules/.bin/dox < lib/search/index.js > docs/json/master/search/index.json & ./node_modules/.bin/dox < lib/search/index-class.js > docs/json/master/search/index-class.json & diff --git a/system-test/pubsub.js b/system-test/pubsub.js index afaf02f4498b..4fbf4c91431f 100644 --- a/system-test/pubsub.js +++ b/system-test/pubsub.js @@ -320,4 +320,51 @@ describe('pubsub', function() { }); }); }); + + describe('IAM', function() { + + it('should get a policy', function(done) { + var topic = pubsub.topic(TOPIC_NAMES[0]); + + topic.iam.getPolicy(function(err, policy) { + assert.ifError(err); + assert.deepEqual(policy, { etag: 'ACAB' }); + done(); + }); + }); + + it('should set a policy', function(done) { + var topic = pubsub.topic(TOPIC_NAMES[0]); + var policy = { + bindings: [{ + role: 'roles/pubsub.publisher', + members: ['serviceAccount:gmail-api-push@system.gserviceaccount.com'] + }] + }; + + topic.iam.setPolicy(policy, function(err, newPolicy) { + assert.ifError(err); + assert.deepEqual(newPolicy.bindings, policy.bindings); + done(); + }); + }); + + it('should test the iam permissions', function(done) { + var topic = pubsub.topic(TOPIC_NAMES[0]); + var testPermissions = [ + 'pubsub.topics.get', + 'pubsub.topics.update' + ]; + + topic.iam.testPermissions(testPermissions, function(err, permissions) { + assert.ifError(err); + assert.deepEqual(permissions, { + 'pubsub.topics.get': true, + 'pubsub.topics.update': true + }); + done(); + }); + }); + + }); }); diff --git a/test/pubsub/iam.js b/test/pubsub/iam.js new file mode 100644 index 000000000000..fff0a3cb05a4 --- /dev/null +++ b/test/pubsub/iam.js @@ -0,0 +1,224 @@ +/** + * Copyright 2014 Google Inc. All Rights Reserved. + * + * 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'; + +var assert = require('assert'); +var IAM = require('../../lib/pubsub/iam'); +var noop = function() {}; + +describe('IAM', function() { + var RESOURCE = 'projects/test-project/topics/test-topic'; + var pubsubMock = { + makeReq_: noop + }; + var iam; + + beforeEach(function() { + iam = new IAM(pubsubMock, RESOURCE); + }); + + describe('initialization', function() { + it('should localize the resource', function() { + assert.strictEqual(iam.resource, RESOURCE); + }); + }); + + describe('getPolicy', function() { + it('should make the correct API request', function(done) { + iam.makeReq_ = function(method, path, q, body) { + assert.strictEqual(method, 'GET'); + + var expectedPath = RESOURCE + ':getIamPolicy'; + assert.strictEqual(path, expectedPath); + + assert.strictEqual(q, null); + assert.strictEqual(body, null); + + done(); + }; + + iam.getPolicy(assert.ifError); + }); + + it('should pass the callback the expected params', function(done) { + var _policy = { + bindings: [{ yo: 'yo' }] + }; + + iam.makeReq_ = function(method, path, q, body, callback) { + callback(null, _policy, _policy); + }; + + iam.getPolicy(function(err, policy, apiResponse) { + assert.ifError(err); + assert.deepEqual(policy, _policy); + assert.deepEqual(apiResponse, _policy); + done(); + }); + }); + + it('should handle errors properly', function(done) { + var fakeResponse = { + error: 'Ohnoes' + }; + var error = new Error(fakeResponse.error); + + iam.makeReq_ = function(method, path, q, body, callback) { + callback(error, fakeResponse); + }; + + iam.getPolicy(function(err, policy, apiResponse) { + assert.strictEqual(err, error); + assert.strictEqual(policy, null); + assert.strictEqual(apiResponse, fakeResponse); + done(); + }); + }); + }); + + describe('setPolicy', function() { + it('should throw an error if a policy is not supplied', function() { + assert.throws(function() { + iam.setPolicy(noop); + }, /A policy is required/); + }); + + it('should make the correct API request', function(done) { + var policy = { etag: 'ACAB' }; + + iam.makeReq_ = function(method, path, q, body) { + assert.strictEqual(method, 'POST'); + + var expectedPath = RESOURCE + ':setIamPolicy'; + assert.strictEqual(path, expectedPath); + + assert.strictEqual(q, null); + + var expectedBody = { policy: policy }; + assert.deepEqual(body, expectedBody); + + done(); + }; + + iam.setPolicy(policy, assert.ifError); + }); + + it('should pass the callback the expected params', function(done) { + var _policy = { + bindings: [{ yo: 'yo' }] + }; + + iam.makeReq_ = function(method, path, q, body, callback) { + callback(null, body.policy, body.policy); + }; + + iam.setPolicy(_policy, function(err, policy, apiResponse) { + assert.ifError(err); + assert.deepEqual(_policy, policy); + assert.deepEqual(_policy, apiResponse); + done(); + }); + }); + + it('should handle errors properly', function(done) { + var fakeResponse = { + error: 'Ohnoes' + }; + var error = new Error(fakeResponse.error); + + iam.makeReq_ = function(method, path, q, body, callback) { + callback(error, fakeResponse); + }; + + iam.setPolicy({}, function(err, policy, apiResponse) { + assert.strictEqual(err, error); + assert.strictEqual(policy, null); + assert.strictEqual(apiResponse, fakeResponse); + done(); + }); + }); + }); + + describe('testPermissions', function() { + it('should throw an error if permissions are missing', function() { + assert.throws(function() { + iam.testPermissions(noop); + }, /Permissions are required/); + }); + + it('should make the correct API request', function(done) { + var permissions = 'storage.bucket.list'; + + iam.makeReq_ = function(method, path, q, body) { + assert.strictEqual(method, 'POST'); + + var expectedPath = RESOURCE + ':testIamPermissions'; + assert.strictEqual(path, expectedPath); + + assert.strictEqual(q, null); + + var expectedBody = { permissions: [permissions] }; + assert.deepEqual(body, expectedBody); + + done(); + }; + + iam.testPermissions(permissions, assert.ifError); + }); + + it('should send an error back if the request fails', function(done) { + var permissions = ['storage.bucket.list']; + var error = new Error('Error.'); + var fakeResponse = {}; + + iam.makeReq_ = function(method, path, q, body, callback) { + callback(error, fakeResponse); + }; + + iam.testPermissions(permissions, function(err, perms, resp) { + assert.strictEqual(err, error); + assert.strictEqual(perms, null); + assert.strictEqual(resp, fakeResponse); + done(); + }); + }); + + it('should pass back a hash of permissions the user has', function(done) { + var permissions = [ + 'storage.bucket.list', + 'storage.bucket.consume' + ]; + var fakeResponse = { + permissions: ['storage.bucket.consume'] + }; + + iam.makeReq_ = function(method, path, q, body, callback) { + callback(null, fakeResponse); + }; + + iam.testPermissions(permissions, function(err, perms, resp) { + assert.ifError(err); + assert.deepEqual(perms, { + 'storage.bucket.list': false, + 'storage.bucket.consume': true + }); + assert.strictEqual(resp, fakeResponse); + done(); + }); + }); + }); +}); diff --git a/test/pubsub/subscription.js b/test/pubsub/subscription.js index 066b38882335..32bc7b647380 100644 --- a/test/pubsub/subscription.js +++ b/test/pubsub/subscription.js @@ -19,8 +19,13 @@ 'use strict'; var assert = require('assert'); +var mockery = require('mockery'); var util = require('../../lib/common/util.js'); -var Subscription = require('../../lib/pubsub/subscription.js'); +var Subscription; + +function FakeIAM() { + this.calledWith_ = [].slice.call(arguments); +} describe('Subscription', function() { var PROJECT_ID = 'test-project'; @@ -48,6 +53,15 @@ describe('Subscription', function() { }; var subscription; + before(function() { + mockery.registerMock('./iam', FakeIAM); + mockery.enable({ + useCleanCache: true, + warnOnUnregistered: false + }); + Subscription = require('../../lib/pubsub/subscription.js'); + }); + beforeEach(function() { subscription = new Subscription(pubsubMock, { name: SUB_NAME }); }); @@ -107,6 +121,13 @@ describe('Subscription', function() { it('should not be paused', function() { assert.strictEqual(subscription.paused, false); }); + + it('should create an iam object', function() { + assert.deepEqual(subscription.iam.calledWith_, [ + pubsubMock, + SUB_FULL_NAME + ]); + }); }); describe('formatName_', function() { diff --git a/test/pubsub/topic.js b/test/pubsub/topic.js index 0aeb77983d38..5e97b4020086 100644 --- a/test/pubsub/topic.js +++ b/test/pubsub/topic.js @@ -17,18 +17,33 @@ 'use strict'; var assert = require('assert'); -var Topic = require('../../lib/pubsub/topic'); +var mockery = require('mockery'); var util = require('../../lib/common/util.js'); +var Topic; + +function FakeIAM() { + this.calledWith_ = [].slice.call(arguments); +} describe('Topic', function() { var PROJECT_ID = 'test-project'; var TOPIC_NAME = 'test-topic'; + var TOPIC_FULL_NAME = 'projects/' + PROJECT_ID + '/topics/' + TOPIC_NAME; var pubsubMock = { projectId: PROJECT_ID, makeReq_: util.noop }; var topic; + before(function() { + mockery.registerMock('./iam', FakeIAM); + mockery.enable({ + useCleanCache: true, + warnOnUnregistered: false + }); + Topic = require('../../lib/pubsub/topic'); + }); + beforeEach(function() { topic = new Topic(pubsubMock, TOPIC_NAME); }); @@ -50,6 +65,13 @@ describe('Topic', function() { it('should assign pubsub object to `this`', function() { assert.deepEqual(topic.pubsub, pubsubMock); }); + + it('should create an iam object', function() { + assert.deepEqual(topic.iam.calledWith_, [ + pubsubMock, + TOPIC_FULL_NAME + ]); + }); }); describe('formatMessage_', function() {