targaryen
Version:
Test Firebase security rules without connecting to Firebase.
418 lines (331 loc) • 10.6 kB
JavaScript
/**
* Create firebase rule set trees from firebase rules objects.
*/
'use strict';
const parser = require('../parser');
const paths = require('../paths');
const dbQuery = require('./query');
const results = require('./results');
/**
* Rule parsing related error.
*
* Holds the the path to the rule in the rules set and append it to the error
* message.
*
*/
class RuleError extends Error {
constructor(stack, message) {
super(`${stack.join('/')}: ${message}`);
this.path = stack;
}
}
/**
* Test the value is an object.
*
* @param {any} value Value to test
* @return {boolean}
*/
function isObject(value) {
return value && (typeof value === 'object');
}
/**
* Test the the rule as the type and of an existing kind (read/write/validate/indexOn).
*
* @param {array} stack Path to the rule in the rule set.
* @param {string} kind The rule kind (read/write/validate/indexOn)
* @param {any} value The rule value
*/
function testRuleType(stack, kind, value) {
const ruleType = typeof value;
switch (kind) {
case '.indexOn':
if (ruleType !== 'string' && !Array.isArray(value)) {
throw new RuleError(stack, `Expected .indexOn to be a string or an Array, got ${ruleType}`);
}
if (Array.isArray(value) && value.some(i => typeof i !== 'string')) {
throw new RuleError(stack, `Expected .indexOn to be an Array of string, got ${value.map(x => typeof x).join(', ')}`);
}
return;
case '.read':
case '.write':
case '.validate':
if (ruleType !== 'string' && ruleType !== 'boolean') {
throw new RuleError(stack, `Expected ${kind} to be a string or a boolean, got ${ruleType}`);
}
return;
default:
throw new RuleError(stack, `Invalid rule types: ${kind}`);
}
}
/**
* Hold a tree of read/write/validate rules.
*
* Used to simulate a firebase read, write or patch (update) operation.
*
*/
class Ruleset {
/**
* Ruleset constructor.
*
* Should throw if the definition cannot be publish on Firebase.
*
* @param {object} rulesDefinition A rule set object.
*/
constructor(rulesDefinition) {
if (!rulesDefinition) {
throw new Error('No rules definition provided');
}
if (!isObject(rulesDefinition)) {
throw new Error('Rules definition must be an object');
}
if (!rulesDefinition.rules || Object.keys(rulesDefinition).length !== 1) {
throw new Error('Rules definition must have a single root object with property "rules"');
}
this.root = new RulesetNode([], rulesDefinition.rules);
Object.freeze(this);
}
/**
* Simulate a read (on/once) operation.
*
* It will traverse the tree from the root to the the node to access until
* it finds a '.read' rule which evaluating to true.
*
* The operation is allowed if it found a read rule evaluating to true.
*
* @param {string} path Path to the node to read
* @param {Database} data Database to read
* @param {{now: number, query: object}} [options] Read options
* @return {{info: string, allowed: boolean}}
*/
tryRead(path, data, options) {
const now = options && options.now;
const query = dbQuery.create(options && options.query);
paths.mustBeValid(path);
const result = results.read(path, data);
let state = {
root: data.snapshot('/'),
auth: result.auth,
query,
now
};
this.root.$traverse(path, (currentPath, rules, wildchildren) => {
if (!rules.$read) {
return;
}
state = Object.assign({}, state, wildchildren, {data: data.snapshot(currentPath)});
Ruleset.evaluate({
rules,
kind: 'read',
path: currentPath,
state,
result,
debug: data.debug
});
return result.allowed;
});
return result;
}
/**
* Evaluate the '.write' and '.validate' rules for `#tryWrite` and `#tryPatch`.
*
* @param {string} path Path to evaluate
* @param {Database} data Original data
* @param {Database} newData Resulting data
* @param {any} newValue Plain value (for the result)
* @return {Result}
* @private
*/
tryWrite(path, data, newData, newValue) {
const stop = true;
const debug = newData.debug;
const result = results.write(path, data, newData, newValue);
let state = {
root: data.snapshot('/'),
auth: data.auth || null,
now: newData.timestamp
};
this.root.$traverse(path, (currentPath, rules, wildchildren) => {
state = Object.assign({}, state, wildchildren, {
data: data.snapshot(currentPath),
newData: newData.snapshot(currentPath)
});
if (result.permitted && !state.newData.exists()) {
return stop;
}
if (!result.permitted) {
Ruleset.evaluate({
rules,
kind: 'write',
path: currentPath,
state,
result,
debug
});
}
if (state.newData.exists()) {
Ruleset.evaluate({
rules,
kind: 'validate',
path: currentPath,
state,
result,
debug
});
}
return !stop;
});
if (!result.newData.exists()) {
return result;
}
newData.walk(path, snap => {
const childPath = snap.toString();
const child = this.root.$child(childPath);
if (!child || !snap.exists()) {
// Note that it only stop walking down that branch; the callback will be
// called with siblings.
return stop;
}
state = Object.assign({}, state, child.wildchildren, {
data: data.snapshot(childPath),
newData: snap
});
Ruleset.evaluate({
rules: child.rules,
kind: 'validate',
path: childPath,
state,
result,
debug
});
return !stop;
});
return result;
}
/**
* Helper function evaluating a rule and logging the result.
*
* @param {{rules: Rule, kind: string, path: string, state: object, result: Result, debug: boolean}} options Options
* @private
*/
static evaluate(options) {
const rule = options.rules[`$${options.kind}`];
if (!rule) {
return;
}
try {
const result = options.debug ? rule.debug(options.state) : {value: rule.evaluate(options.state)};
options.result.add(options.path, options.kind, rule, result);
} catch (error) {
options.result.add(options.path, options.kind, rule, {error});
}
}
}
/**
* Represent a Rule Node
*/
class RulesetNode {
/**
* RulesetNode constructor.
*
* @param {array} stack Path to the rule in the ruleset.
* @param {object} rules Node rules and its children
*/
constructor(stack, rules) {
if (!rules) {
throw new RuleError(stack, 'no rule provided');
}
if (!isObject(rules)) {
throw new RuleError(stack, `rules should be an object, got ${typeof rules}`);
}
// validate rule kinds
const ruleKinds = Object.keys(rules).filter(k => k.startsWith('.'));
ruleKinds.forEach(k => testRuleType(stack, k, rules[k]));
// validate wildchild
const wildchildren = stack.filter(k => k.startsWith('$'));
const wildchild = Object.keys(rules).filter(k => k.startsWith('$'));
if (wildchild.length > 1) {
throw new RuleError(stack, 'There can only be one wildchild at a given path.');
}
const wildchildName = wildchild.pop();
if (wildchildName && wildchildren.indexOf(wildchildName) > -1) {
throw new RuleError(stack, 'got identical wildchild names in the stack.');
}
// Setup flag and parse rules.
const isWrite = true;
const name = stack.slice(-1).pop();
Object.defineProperties(this, {
$name: {value: name},
$isWildchild: {value: name && name.startsWith('$')},
$read: {value: rules['.read'] == null ? null : parser.parse(rules['.read'].toString(), wildchildren, !isWrite)},
$write: {value: rules['.write'] == null ? null : parser.parse(rules['.write'].toString(), wildchildren, isWrite)},
$validate: {value: rules['.validate'] == null ? null : parser.parse(rules['.validate'].toString(), wildchildren, isWrite)}
});
// setup children rules
const children = Object.keys(rules).filter(k => !k.startsWith('.') && !k.startsWith('$'));
children.forEach(k => {
this[k] = new RulesetNode(stack.concat(k), rules[k]);
});
this.$wildchild = wildchildName ? new RulesetNode(stack.concat(wildchildName), rules[wildchildName]) : null;
Object.freeze(this);
}
/**
* Find a children rule applying to the name.
*
* @param {string} name Path to the the rule node
* @param {object} wildchildren Map of wildchild name/value to extend
* @return {{child: RulesetNode, wildchildren: object}|void}
*/
$child(name, wildchildren) {
wildchildren = wildchildren || {};
const parts = paths.split(name);
let rules = this;
for (let i = 0; i < parts.length; i++) {
const key = parts[i];
if (rules[key]) {
rules = rules[key];
continue;
}
if (!rules.$wildchild) {
return;
}
rules = rules.$wildchild;
wildchildren = Object.assign({}, wildchildren, {[rules.$name]: key});
}
return {rules, wildchildren};
}
/**
* Traverse the path and yield each node on the way.
*
* The callback function can return `true` to stop traversing the path.
*
* @param {string} path Path to traverse
* @param {object} [wildchildren] Map of wildchild name/value to extend
* @param {function(path: string, rules: RulesetNode, wildchildren: object): boolean} cb Receive each node traversed.
*/
$traverse(path, wildchildren, cb) {
let currentPath = '';
let currentRules = this;
if (typeof wildchildren === 'function') {
cb = wildchildren;
wildchildren = {};
}
cb(currentPath, currentRules, wildchildren);
const stop = true;
paths.split(path).some(key => {
const child = currentRules.$child(key, wildchildren);
if (!child) {
return stop;
}
currentPath = paths.join(currentPath, key);
currentRules = child.rules;
wildchildren = child.wildchildren;
return cb(currentPath, currentRules, wildchildren);
});
}
}
exports.create = function(rulesDefinition) {
if (rulesDefinition instanceof Ruleset) {
return rulesDefinition;
}
return new Ruleset(rulesDefinition);
};