Skip to content

Adds support for read-only masterKey #4297

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 4 commits into from
Oct 26, 2017
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,7 @@ The client keys used with Parse are no longer necessary with Parse Server. If yo
* `customPages` - A hash with urls to override email verification links, password reset links and specify frame url for masking user-facing pages. Available keys: `parseFrameURL`, `invalidLink`, `choosePassword`, `passwordResetSuccess`, `verifyEmailSuccess`.
* `middleware` - (CLI only), a module name, function that is an express middleware. When using the CLI, the express app will load it just **before** mounting parse-server on the mount path. This option is useful for injecting a monitoring middleware.
* `masterKeyIps` - The array of ip addresses where masterKey usage will be restricted to only these ips. (Default to [] which means allow all ips). If you're using this feature and have `useMasterKey: true` in cloudcode, make sure that you put your own ip in this list.
* `readOnlyMasterKey` - A masterKey that has full read access to the data, but no write access. This key should be treated the same way as your masterKey, keeping it private.

##### Logging

Expand Down
1 change: 1 addition & 0 deletions spec/helper.js
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ var defaultConfiguration = {
restAPIKey: 'rest',
webhookKey: 'hook',
masterKey: 'test',
readOnlyMasterKey: 'read-only-test',
fileKey: 'test',
silent,
logLevel,
Expand Down
135 changes: 135 additions & 0 deletions spec/rest.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ var auth = require('../src/Auth');
var Config = require('../src/Config');
var Parse = require('parse/node').Parse;
var rest = require('../src/rest');
var RestWrite = require('../src/RestWrite');
var request = require('request');
var rp = require('request-promise');

Expand Down Expand Up @@ -623,5 +624,139 @@ describe('rest update', () => {
done();
});
});
});

describe('read-only masterKey', () => {
it('properly throws on rest.create, rest.update and rest.del', () => {
const config = Config.get('test');
const readOnly = auth.readOnly(config);
expect(() => {
rest.create(config, readOnly, 'AnObject', {})
}).toThrow(new Parse.Error(Parse.Error.OPERATION_FORBIDDEN, `read-only masterKey isn't allowed to perform the create operation.`));
expect(() => {
rest.update(config, readOnly, 'AnObject', {})
}).toThrow();
expect(() => {
rest.del(config, readOnly, 'AnObject', {})
}).toThrow();
});

it('properly blocks writes', (done) => {
reconfigureServer({
readOnlyMasterKey: 'yolo-read-only'
}).then(() => {
return rp.post(`${Parse.serverURL}/classes/MyYolo`, {
headers: {
'X-Parse-Application-Id': Parse.applicationId,
'X-Parse-Master-Key': 'yolo-read-only',
},
json: { foo: 'bar' }
});
}).then(done.fail).catch((res) => {
expect(res.error.code).toBe(Parse.Error.OPERATION_FORBIDDEN);
expect(res.error.error).toBe('read-only masterKey isn\'t allowed to perform the create operation.');
done();
});
});

it('should throw when masterKey and readOnlyMasterKey are the same', (done) => {
reconfigureServer({
masterKey: 'yolo',
readOnlyMasterKey: 'yolo'
}).then(done.fail).catch((err) => {
expect(err).toEqual(new Error('masterKey and readOnlyMasterKey should be different'));
done();
});
});

it('should throw when trying to create RestWrite', () => {
const config = Config.get('test');
expect(() => {
new RestWrite(config, auth.readOnly(config));
}).toThrow(new Parse.Error(Parse.Error.OPERATION_FORBIDDEN, 'Cannot perform a write operation when using readOnlyMasterKey'));
});

it('should throw when trying to create schema', (done) => {
return rp.post(`${Parse.serverURL}/schemas`, {
headers: {
'X-Parse-Application-Id': Parse.applicationId,
'X-Parse-Master-Key': 'read-only-test',
},
json: {}
}).then(done.fail).catch((res) => {
expect(res.error.code).toBe(Parse.Error.OPERATION_FORBIDDEN);
expect(res.error.error).toBe('read-only masterKey isn\'t allowed to create a schema.');
done();
});
});

it('should throw when trying to create schema with a name', (done) => {
return rp.post(`${Parse.serverURL}/schemas/MyClass`, {
headers: {
'X-Parse-Application-Id': Parse.applicationId,
'X-Parse-Master-Key': 'read-only-test',
},
json: {}
}).then(done.fail).catch((res) => {
expect(res.error.code).toBe(Parse.Error.OPERATION_FORBIDDEN);
expect(res.error.error).toBe('read-only masterKey isn\'t allowed to create a schema.');
done();
});
});

it('should throw when trying to update schema', (done) => {
return rp.put(`${Parse.serverURL}/schemas/MyClass`, {
headers: {
'X-Parse-Application-Id': Parse.applicationId,
'X-Parse-Master-Key': 'read-only-test',
},
json: {}
}).then(done.fail).catch((res) => {
expect(res.error.code).toBe(Parse.Error.OPERATION_FORBIDDEN);
expect(res.error.error).toBe('read-only masterKey isn\'t allowed to update a schema.');
done();
});
});

it('should throw when trying to delete schema', (done) => {
return rp.del(`${Parse.serverURL}/schemas/MyClass`, {
headers: {
'X-Parse-Application-Id': Parse.applicationId,
'X-Parse-Master-Key': 'read-only-test',
},
json: {}
}).then(done.fail).catch((res) => {
expect(res.error.code).toBe(Parse.Error.OPERATION_FORBIDDEN);
expect(res.error.error).toBe('read-only masterKey isn\'t allowed to delete a schema.');
done();
});
});

it('should throw when trying to update the global config', (done) => {
return rp.put(`${Parse.serverURL}/config`, {
headers: {
'X-Parse-Application-Id': Parse.applicationId,
'X-Parse-Master-Key': 'read-only-test',
},
json: {}
}).then(done.fail).catch((res) => {
expect(res.error.code).toBe(Parse.Error.OPERATION_FORBIDDEN);
expect(res.error.error).toBe('read-only masterKey isn\'t allowed to update the config.');
done();
});
});

it('should throw when trying to send push', (done) => {
return rp.post(`${Parse.serverURL}/push`, {
headers: {
'X-Parse-Application-Id': Parse.applicationId,
'X-Parse-Master-Key': 'read-only-test',
},
json: {}
}).then(done.fail).catch((res) => {
expect(res.error.code).toBe(Parse.Error.OPERATION_FORBIDDEN);
expect(res.error.error).toBe('read-only masterKey isn\'t allowed to send push notifications.');
done();
});
});
});
15 changes: 11 additions & 4 deletions src/Auth.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,12 @@ var RestQuery = require('./RestQuery');
// An Auth object tells you who is requesting something and whether
// the master key was used.
// userObject is a Parse.User and can be null if there's no user.
function Auth({ config, isMaster = false, user, installationId } = {}) {
function Auth({ config, isMaster = false, isReadOnly = false, user, installationId } = {}) {
this.config = config;
this.installationId = installationId;
this.isMaster = isMaster;
this.user = user;
this.isReadOnly = isReadOnly;

// Assuming a users roles won't change during a single request, we'll
// only load them once.
Expand All @@ -34,6 +35,11 @@ function master(config) {
return new Auth({ config, isMaster: true });
}

// A helper to get a master-level Auth object
function readOnly(config) {
return new Auth({ config, isMaster: true, isReadOnly: true });
}

// A helper to get a nobody-level Auth object
function nobody(config) {
return new Auth({ config, isMaster: false });
Expand Down Expand Up @@ -207,9 +213,10 @@ Auth.prototype._getAllRolesNamesForRoleIds = function(roleIDs, names = [], queri
}

module.exports = {
Auth: Auth,
master: master,
nobody: nobody,
Auth,
master,
nobody,
readOnly,
getAuthForSessionToken,
getAuthForLegacySessionToken
};
9 changes: 8 additions & 1 deletion src/Config.js
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,15 @@ export class Config {
emailVerifyTokenValidityDuration,
accountLockout,
passwordPolicy,
masterKeyIps
masterKeyIps,
masterKey,
readOnlyMasterKey,
}) {

if (masterKey === readOnlyMasterKey) {
throw new Error('masterKey and readOnlyMasterKey should be different');
}

const emailAdapter = userController.adapter;
if (verifyUserEmails) {
this.validateEmailConfiguration({emailAdapter, appName, publicServerURL, emailVerifyTokenValidityDuration});
Expand Down
4 changes: 4 additions & 0 deletions src/Options/Definitions.js
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,10 @@ module.exports.ParseServerOptions = {
"env": "PARSE_SERVER_REST_API_KEY",
"help": "Key for REST calls"
},
"readOnlyMasterKey": {
"env": "PARSE_SERVER_READ_ONLY_MASTER_KEY",
"help": "Read-only key, which has the same capabilities as MasterKey without writes"
},
"webhookKey": {
"env": "PARSE_SERVER_WEBHOOK_KEY",
"help": "Key sent with outgoing webhook calls"
Expand Down
2 changes: 2 additions & 0 deletions src/Options/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ export interface ParseServerOptions {
/* Key for REST calls
:ENV: PARSE_SERVER_REST_API_KEY */
restAPIKey: ?string;
/* Read-only key, which has the same capabilities as MasterKey without writes */
readOnlyMasterKey: ?string;
/* Key sent with outgoing webhook calls */
webhookKey: ?string;
/* Key for your files */
Expand Down
3 changes: 3 additions & 0 deletions src/RestWrite.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ import _ from 'lodash';
// everything. It also knows to use triggers and special modifications
// for the _User class.
function RestWrite(config, auth, className, query, data, originalData, clientSDK) {
if (auth.isReadOnly) {
throw new Parse.Error(Parse.Error.OPERATION_FORBIDDEN, 'Cannot perform a write operation when using readOnlyMasterKey');
}
this.config = config;
this.auth = auth;
this.className = className;
Expand Down
5 changes: 4 additions & 1 deletion src/Routers/GlobalConfigRouter.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// global_config.js

import Parse from 'parse/node';
import PromiseRouter from '../PromiseRouter';
import * as middleware from "../middlewares";

Expand All @@ -16,6 +16,9 @@ export class GlobalConfigRouter extends PromiseRouter {
}

updateGlobalConfig(req) {
if (req.auth.isReadOnly) {
throw new Parse.Error(Parse.Error.OPERATION_FORBIDDEN, 'read-only masterKey isn\'t allowed to update the config.');
}
const params = req.body.params;
// Transform in dot notation to make sure it works
const update = Object.keys(params).reduce((acc, key) => {
Expand Down
3 changes: 3 additions & 0 deletions src/Routers/PushRouter.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ export class PushRouter extends PromiseRouter {
}

static handlePOST(req) {
if (req.auth.isReadOnly) {
throw new Parse.Error(Parse.Error.OPERATION_FORBIDDEN, 'read-only masterKey isn\'t allowed to send push notifications.');
}
const pushController = req.config.pushController;
if (!pushController) {
throw new Parse.Error(Parse.Error.PUSH_MISCONFIGURED, 'Push controller is not set');
Expand Down
9 changes: 9 additions & 0 deletions src/Routers/SchemasRouter.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ function getOneSchema(req) {
}

function createSchema(req) {
if (req.auth.isReadOnly) {
throw new Parse.Error(Parse.Error.OPERATION_FORBIDDEN, 'read-only masterKey isn\'t allowed to create a schema.');
}
if (req.params.className && req.body.className) {
if (req.params.className != req.body.className) {
return classNameMismatchResponse(req.body.className, req.params.className);
Expand All @@ -51,6 +54,9 @@ function createSchema(req) {
}

function modifySchema(req) {
if (req.auth.isReadOnly) {
throw new Parse.Error(Parse.Error.OPERATION_FORBIDDEN, 'read-only masterKey isn\'t allowed to update a schema.');
}
if (req.body.className && req.body.className != req.params.className) {
return classNameMismatchResponse(req.body.className, req.params.className);
}
Expand All @@ -64,6 +70,9 @@ function modifySchema(req) {
}

const deleteSchema = req => {
if (req.auth.isReadOnly) {
throw new Parse.Error(Parse.Error.OPERATION_FORBIDDEN, 'read-only masterKey isn\'t allowed to delete a schema.');
}
if (!SchemaController.classNameIsValid(req.params.className)) {
throw new Parse.Error(Parse.Error.INVALID_CLASS_NAME, SchemaController.invalidClassNameMessage(req.params.className));
}
Expand Down
7 changes: 7 additions & 0 deletions src/middlewares.js
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,13 @@ export function handleParseHeaders(req, res, next) {
return;
}

var isReadOnlyMaster = (info.masterKey === req.config.readOnlyMasterKey);
if (typeof req.config.readOnlyMasterKey != 'undefined' && req.config.readOnlyMasterKey && isReadOnlyMaster) {
req.auth = new auth.Auth({ config: req.config, installationId: info.installationId, isMaster: true, isReadOnly: true });
next();
return;
}

// Client keys are not required in parse-server, but if any have been configured in the server, validate them
// to preserve original behavior.
const keys = ["clientKey", "javascriptKey", "dotNetKey", "restAPIKey"];
Expand Down
6 changes: 6 additions & 0 deletions src/rest.js
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,12 @@ function enforceRoleSecurity(method, className, auth) {
const error = `Clients aren't allowed to perform the ${method} operation on the ${className} collection.`
throw new Parse.Error(Parse.Error.OPERATION_FORBIDDEN, error);
}

// readOnly masterKey is not allowed
if (auth.isReadOnly && (method === 'delete' || method === 'create' || method === 'update')) {
const error = `read-only masterKey isn't allowed to perform the ${method} operation.`
throw new Parse.Error(Parse.Error.OPERATION_FORBIDDEN, error);
}
}

module.exports = {
Expand Down