diff --git a/package.json b/package.json index e0eb164..932f111 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,7 @@ "babel-runtime": "^6.6.1", "cryptiles": "^2.0.4", "debug": "^2.2.0", - "fast-azure-storage": "^0.3.5", + "fast-azure-storage": "^1.0.0", "hawk": "2.3.0", "hoek": "^2.16.3", "lodash": "^3.9.3", diff --git a/schemas/azure-blob-response.yml b/schemas/azure-blob-response.yml new file mode 100644 index 0000000..2a60afc --- /dev/null +++ b/schemas/azure-blob-response.yml @@ -0,0 +1,22 @@ +$schema: http://json-schema.org/draft-04/schema# +title: "Azure Blob Shared-Access-Signature Response" +description: | + Response to a request for an Shared-Access-Signature to access an Azure + Blob Storage container. +type: object +properties: + sas: + description: | + Shared-Access-Signature string. This is the querystring parameters to + be appened after `?` or `&` depending on whether or not a querystring is + already present in the URL. + type: string + expiry: + description: | + Date and time of when the Shared-Access-Signature expires. + type: string + format: date-time +additionalProperties: false +required: + - sas + - expiry diff --git a/schemas/azure-table-access-response.yml b/schemas/azure-table-access-response.yml index eee69f1..ce27665 100644 --- a/schemas/azure-table-access-response.yml +++ b/schemas/azure-table-access-response.yml @@ -1,5 +1,5 @@ $schema: http://json-schema.org/draft-04/schema# -title: "Azure Shared-Access-Signature Response" +title: "Azure Table Shared-Access-Signature Response" description: | Response to a request for an Shared-Access-Signature to access and Azure Table Storage table. diff --git a/src/azure.js b/src/azure.js index 0cf00df..e78f121 100644 --- a/src/azure.js +++ b/src/azure.js @@ -73,12 +73,6 @@ api.declare({ var tableName = req.params.table; var level = req.params.level; - if (!['read-write', 'read-only'].includes(level)) { - return res.status(404).json({ - message: "Level '" + level + "' is not valid. Must be one of ['read-write', 'read-only']." - }); - } - // We have a complicated scope situation for read-only since we want // read-write to grant read-only permissions as well if (!(level === 'read-only' && req.satisfies({account, table: tableName, level: 'read-write'}, true)) && @@ -88,9 +82,8 @@ api.declare({ // Check that the account exists if (!this.azureAccounts[account]) { - return res.status(404).json({ - message: "Account '" + account + "' not found, can't delegate access" - }); + return res.reportError('ResourceNotFound', + `Account '${account}' not found, can't delegate access`); } // Construct client @@ -131,3 +124,79 @@ api.declare({ expiry: expiry.toJSON() }); }); + +api.declare({ + method: 'get', + route: '/azure/:account/containers/:container/:level', + name: 'azureBlobSAS', + input: undefined, + output: 'azure-blob-response.json#', + deferAuth: true, + stability: 'stable', + scopes: [['auth:azure-blob::/']], + title: "Get Shared-Access-Signature for Azure Blob", + description: [ + "Get a shared access signature (SAS) string for use with a specific Azure", + "Blob Storage container. If the level is read-write, the container will be created, " + + "if it doesn't already exists." + ].join('\n') +}, async function(req, res){ + // Get parameters + let account = req.params.account; + let container = req.params.container; + let level = req.params.level; + + // Check that the client is authorized to access given account and container + if (!(level === 'read-only' && + req.satisfies({account, container, level: 'read-write'}, true)) && + !req.satisfies({account, container, level})) { + return; + } + + // Check that the account exists + if (!this.azureAccounts[account]) { + return res.reportError('ResourceNotFound', + `Account '${level}' not found, can't delegate access.`); + } + + // Construct client + let blob = new azure.Blob({ + accountId: account, + accessKey: this.azureAccounts[account] + }); + + // Create container ignore error, if it already exists + if (level === 'read-write') { + try { + await blob.createContainer(container); + } catch (err) { + if (err.code !== 'ContainerAlreadyExists') { + throw err; + } + } + } + + let perm = level === 'read-write'; + + // Construct SAS + let expiry = new Date(Date.now() + 25 * 60 * 1000); + let sas = blob.sas(container, null, { + start: new Date(Date.now() - 15 * 60 * 1000), + expiry: expiry, + resourceType: 'container', + permissions: { + read: true, + add: perm, + create: perm, + write: perm, + delete: perm, + list: true, + } + }); + + // Return the generated SAS + return res.reply({ + sas: sas, + expiry: expiry.toJSON() + }); +}); \ No newline at end of file diff --git a/src/v1.js b/src/v1.js index 8c6bb6f..8ee9a76 100644 --- a/src/v1.js +++ b/src/v1.js @@ -55,6 +55,8 @@ var api = new API({ // Patterns for Azure account: /^[a-z0-9]{3,24}$/, table: /^[A-Za-z][A-Za-z0-9]{2,62}$/, + container: /^(?!.*[-]{2})[a-z0-9][a-z0-9-]{1,61}[a-z0-9]$/, + level: /^(read-write|read-only)$/, // Patterns for AWS bucket: /^[a-z0-9][a-z0-9-]{1,62}[a-z0-9]$/, diff --git a/test/azure_test.js b/test/azure_test.js index 500443f..71b2fe1 100644 --- a/test/azure_test.js +++ b/test/azure_test.js @@ -1,5 +1,4 @@ -suite('azure table (sas)', function() { - var Promise = require('promise'); +suite.only('azure table and blob (sas)', function() { var assert = require('assert'); var debug = require('debug')('auth:test:azure'); var helper = require('./helper'); @@ -92,7 +91,7 @@ suite('azure table (sas)', function() { ).then(function(result) { assert(false, "This should have thrown an error"); }).catch(function(err) { - assert.equal(err.message, "Level 'foo-bar-baz' is not valid. Must be one of ['read-write', 'read-only']."); + assert.equal(err.code, "InvalidRequestArguments"); }); }); @@ -101,16 +100,11 @@ suite('azure table (sas)', function() { accessToken: helper.rootAccessToken }; - - test('azureTableSAS (allowed table)', function() { + test('azureTableSAS (allowed table)', () => { // Restrict access a bit - var auth = new helper.Auth({ - baseUrl: helper.baseUrl, - credentials: rootCredentials, - authorizedScopes: [ - 'auth:azure-table:read-write:' + helper.testaccount + '/allowedTable' - ] - }); + let auth = helper.scopes([ + 'auth:azure-table:read-write:' + helper.testaccount + '/allowedTable' + ]); return auth.azureTableSAS( helper.testaccount, 'allowedTable', @@ -124,13 +118,9 @@ suite('azure table (sas)', function() { test('azureTableSAS (allowed table rw -> ro)', function() { // Restrict access a bit - var auth = new helper.Auth({ - baseUrl: helper.baseUrl, - credentials: rootCredentials, - authorizedScopes: [ - 'auth:azure-table:read-write:' + helper.testaccount + '/allowedTable' - ] - }); + let auth = helper.scopes([ + 'auth:azure-table:read-write:' + helper.testaccount + '/allowedTable' + ]); return auth.azureTableSAS( helper.testaccount, 'allowedTable', @@ -144,13 +134,9 @@ suite('azure table (sas)', function() { test('azureTableSAS (too high permission)', function() { // Restrict access a bit - var auth = new helper.Auth({ - baseUrl: helper.baseUrl, - credentials: rootCredentials, - authorizedScopes: [ - 'auth:azure-table:read-only:' + helper.testaccount + '/allowedTable' - ] - }); + let auth = helper.scopes([ + 'auth:azure-table:read-only:' + helper.testaccount + '/allowedTable' + ]); return auth.azureTableSAS( helper.testaccount, 'allowedTable', @@ -164,13 +150,9 @@ suite('azure table (sas)', function() { test('azureTableSAS (unauthorized table)', function() { // Restrict access a bit - var auth = new helper.Auth({ - baseUrl: helper.baseUrl, - credentials: rootCredentials, - authorizedScopes: [ - 'auth:azure-table:read-write:' + helper.testaccount + '/allowedTable' - ] - }); + let auth = helper.scopes([ + 'auth:azure-table:read-write:' + helper.testaccount + '/allowedTable' + ]); return auth.azureTableSAS( helper.testaccount, 'unauthorizedTable', @@ -181,4 +163,121 @@ suite('azure table (sas)', function() { assert(err.statusCode == 403, "Expected authorization error!"); }); }); -}); + + test('azureBlobSAS', async () => { + let result = await helper.auth.azureBlobSAS( + helper.testaccount, + 'container-test', + 'read-write' + ); + + assert(typeof(result.sas) === 'string', "Expected some form of string"); + assert(new Date(result.expiry).getTime() > new Date().getTime(), + "Expected expiry to be in the future"); + }); + + test('azureBlobSAS (read-write)', async () => { + let result = await helper.auth.azureBlobSAS( + helper.testaccount, + 'container-test', + 'read-write', + ); + assert(typeof(result.sas) === 'string', "Expected some form of string"); + assert(new Date(result.expiry).getTime() > new Date().getTime(), + "Expected expiry to be in the future"); + + let blob = new azure.Blob({ + accountId: helper.testaccount, + sas: result.sas + }); + + result = await blob.putBlob('container-test', 'blobTest', {type: 'BlockBlob'}); + assert(result); + }); + + test('azureBlobSAS (read-only)', async () => { + let result = await helper.auth.azureBlobSAS( + helper.testaccount, + 'container-test', + 'read-only', + ); + assert(typeof(result.sas) === 'string', "Expected some form of string"); + assert(new Date(result.expiry).getTime() > new Date().getTime(), + "Expected expiry to be in the future"); + + let blob = new azure.Blob({ + accountId: helper.testaccount, + sas: result.sas + }); + + try { + await blob.putBlob('container-test', 'blobTest', {type: 'BlockBlob'}); + } catch (error) { + assert.equal(error.code, 'AuthorizationPermissionMismatch'); + return; + } + assert(false, 'This should have thrown an error because the write is not allowed.'); + }); + + test('azureBlobSAS (invalid level)', async () => { + try { + await helper.auth.azureBlobSAS( + helper.testaccount, + 'container-test', + 'foo-bar-baz', + ); + } catch (error) { + assert.equal(error.code, 'InvalidRequestArguments'); + return; + } + assert(false, "This should have thrown an error"); + }); + + test('azureBlobSAS (allowed container)', async () => { + let auth = helper.scopes([ + 'auth:azure-blob:read-write:' + helper.testaccount + '/allowed-container' + ]); + + let result = await auth.azureBlobSAS( + helper.testaccount, + 'allowed-container', + 'read-write' + ); + + assert(typeof(result.sas) === 'string', "Expected some form of string"); + assert(new Date(result.expiry).getTime() > new Date().getTime(), + "Expected expiry to be in the future"); + }); + + test('azureBlobSAS (allowed read-write -> read-only)', async () => { + let auth = helper.scopes([ + 'auth:azure-blob:read-write:' + helper.testaccount + '/allowed-container' + ]); + + let result = await auth.azureBlobSAS( + helper.testaccount, + 'allowed-container', + 'read-only', + ); + assert(typeof(result.sas) === 'string', "Expected some form of string"); + assert(new Date(result.expiry).getTime() > new Date().getTime(), + "Expected expiry to be in the future"); + }); + + test('azureBlobSAS (unauthorized container)', async () => { + let auth = helper.scopes([ + 'auth:azure-blob:read-write:' + helper.testaccount + '/allowed-container' + ]); + try { + await auth.azureBlobSAS( + helper.testaccount, + 'unauthorized-container', + 'read-write' + ); + } catch (error) { + assert(error.statusCode == 403, "Expected authorization error!"); + return; + } + assert(false, "Expected an authentication error!"); + }); +}); \ No newline at end of file diff --git a/test/helper.js b/test/helper.js index 415289e..3f0d91e 100644 --- a/test/helper.js +++ b/test/helper.js @@ -81,6 +81,17 @@ mocha.before(async () => { } }); + helper.scopes = (scopes) => { + return new helper.Auth({ + baseUrl: helper.baseUrl, + credentials: { + clientId: 'root', + accessToken: cfg.app.rootAccessToken, + }, + authorizedScopes: scopes, + }); + } + // Create test server let { server: testServer_,