UNPKG

gitlab-acebase

Version:

AceBase realtime database server (webserver endpoint to allow remote connections)

381 lines 20.4 kB
"use strict"; var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } return new (P || (P = Promise))(function (resolve, reject) { function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } step((generator = generator.apply(thisArg, _arguments || [])).next()); }); }; Object.defineProperty(exports, "__esModule", { value: true }); exports.PathBasedRules = exports.AccessRuleValidationError = void 0; const acebase_core_1 = require("acebase-core"); const fs = require("fs"); const sandbox_1 = require("./sandbox"); const settings_1 = require("./settings"); class AccessRuleValidationError extends Error { constructor(result) { super(result.message); this.result = result; } } exports.AccessRuleValidationError = AccessRuleValidationError; class PathBasedRules { stop() { throw new Error('not started yet'); } constructor(rulesFilePath, defaultAccess, env) { // Reads rules from a file and monitors it // Check if there is a rules file, load it or generate default this.codeRules = []; this.db = env.db; this.debug = env.debug; const readRules = () => { try { // TODO: store in db itself under __rules__ or in separate rules.db storage file const json = fs.readFileSync(rulesFilePath, 'utf-8'); const obj = JSON.parse(json); if (typeof obj !== 'object' || typeof obj.rules !== 'object') { throw new Error(`malformed rules object`); } return obj; } catch (err) { env.debug.error(`Failed to read rules from "${rulesFilePath}": ${err.message}`); return defaultRules; } }; const defaultAccessRule = (def => { switch (def) { case settings_1.AUTH_ACCESS_DEFAULT.ALLOW_AUTHENTICATED: { return 'auth !== null'; } case settings_1.AUTH_ACCESS_DEFAULT.ALLOW_ALL: { return true; } case settings_1.AUTH_ACCESS_DEFAULT.DENY_ALL: { return false; } default: { env.debug.error(`Unknown defaultAccessRule "${def}"`); return false; } } })(defaultAccess); const defaultRules = { rules: { '.read': defaultAccessRule, '.write': defaultAccessRule, }, }; let accessRules = defaultRules; if (!fs.existsSync(rulesFilePath)) { // Write defaults fs.writeFileSync(rulesFilePath, JSON.stringify(defaultRules, null, 4)); } else { accessRules = readRules(); } // Convert string rules to functions that can be executed const processRules = (path, parent, variables) => { Object.keys(parent).forEach(key => { const rule = parent[key]; if (['.read', '.write', '.validate'].includes(key) && typeof rule === 'string') { let ruleCode = rule.includes('return ') ? rule : `return ${rule}`; // Add `await`s to `value` and `exists` call expressions ruleCode = ruleCode.replace(/(value|exists)\(/g, (m, fn) => `await ${fn}(`); // Convert to function // rule = eval( // `(async (env) => {` + // ` const { now, path, ${variables.join(', ')}, operation, data, auth, value, exists } = env;` + // ` ${ruleCode};` + // `})`); // rule.getText = () => { // return ruleCode; // }; ruleCode = `(async () => {\n${ruleCode}\n})();`; return parent[key] = ruleCode; } else if (key === '.schema') { // Add schema return env.db.schema.set(path, rule) .catch(err => { env.debug.error(`Error parsing ${path}/.schema: ${err.message}`); }); } else if (key.startsWith('$')) { variables.push(key); } if (typeof rule === 'object') { processRules(`${path}/${key}`, rule, variables.slice()); } }); }; processRules('', accessRules.rules, []); // Watch file for changes. watchFile will poll for changes every (default) 5007ms const watchFileListener = () => { // Reload access rules from file const accessRules = readRules(); processRules('', accessRules.rules, []); this.accessRules = accessRules; // Re-add rules added by code const codeRules = this.codeRules.splice(0); for (const rule of codeRules) { this.add(rule.path, rule.type, rule.callback); } }; fs.watchFile(rulesFilePath, watchFileListener); this.stop = () => { fs.unwatchFile(rulesFilePath, watchFileListener); }; process.on('SIGINT', this.stop); this.authEnabled = env.authEnabled; this.accessRules = accessRules; } isOperationAllowed(user, path, operation, data) { return __awaiter(this, void 0, void 0, function* () { // Process rules, find out if signed in user is allowed to read/write // Defaults to false unless a rule is found that tells us otherwise const isPreFlight = typeof data === 'undefined'; const allow = { allow: true }; if (!this.authEnabled) { // Authentication is disabled, anyone can do anything. Not really a smart thing to do! return allow; } else if ((user === null || user === void 0 ? void 0 : user.uid) === 'admin') { // Always allow admin access // TODO: implement user.is_admin, so the default admin account can be disabled return allow; } else if (path.startsWith('__')) { // NEW: with the auth database now integrated into the main database, // deny access to private resources starting with '__' for non-admins return { allow: false, code: 'private', message: `Access to private resource "${path}" not allowed` }; } const getFullPath = (path, relativePath) => { if (relativePath.startsWith('/')) { // Absolute path return relativePath; } else if (!relativePath.startsWith('.')) { throw new Error('Path must be either absolute (/) or relative (./ or ../)'); } let targetPathInfo = acebase_core_1.PathInfo.get(path); const trailKeys = acebase_core_1.PathInfo.getPathKeys(relativePath); trailKeys.forEach(key => { if (key === '.') { /* no op */ } else if (key === '..') { targetPathInfo = targetPathInfo.parent; } else { targetPathInfo = targetPathInfo.child(key); } }); return targetPathInfo.path; }; const env = { now: Date.now(), auth: user || null, operation, vars: {}, context: typeof (data === null || data === void 0 ? void 0 : data.context) === 'object' && data.context !== null ? Object.assign({}, data.context) : {}, }; const pathInfo = acebase_core_1.PathInfo.get(path); const pathKeys = pathInfo.keys.slice(); let rule = this.accessRules.rules; const rulePathKeys = []; let currentPath = ''; let isAllowed = false; while (rule) { // Check read/write access or validate operation const checkRules = []; const applyRule = (rule) => { if (rule && !checkRules.includes(rule)) { checkRules.push(rule); } }; if (['get', 'exists', 'query', 'reflect', 'export', 'transact'].includes(operation)) { // Operations that require 'read' access applyRule(rule['.read']); } if ('.write' in rule && ['update', 'set', 'delete', 'import', 'transact'].includes(operation)) { // Operations that require 'write' access applyRule(rule['.write']); } if (`.${operation}` in rule && !isPreFlight) { // If there is a dedicated rule (eg ".update" or ".reflect") for this operation, use it. applyRule(rule[`.${operation}`]); } const rulePath = acebase_core_1.PathInfo.get(rulePathKeys).path; for (const rule of checkRules) { if (typeof rule === 'boolean') { if (!rule) { return { allow: false, code: 'rule', message: `${operation} operation denied to path "${path}" by set rule`, rule, rulePath }; } isAllowed = true; // return allow; } if (typeof rule === 'string' || typeof rule === 'function') { try { // Execute rule function const ruleEnv = Object.assign(Object.assign({}, env), { exists: (target) => __awaiter(this, void 0, void 0, function* () { return this.db.ref(getFullPath(currentPath, target)).exists(); }), value: (target, include) => __awaiter(this, void 0, void 0, function* () { const snap = yield this.db.ref(getFullPath(currentPath, target)).get({ include }); return snap.val(); }) }); const result = typeof rule === 'function' ? yield rule(ruleEnv) : yield (0, sandbox_1.executeSandboxed)(rule, ruleEnv); if (!['cascade', 'deny', 'allow', true, false].includes(result)) { this.debug.warn(`rule for path ${rulePath} possibly returns an unintentional value (${JSON.stringify(result)}) which results in outcome "${result ? 'allow' : 'deny'}"`); } isAllowed = result === 'allow' || result === true; if (!isAllowed && result !== 'cascade') { return { allow: false, code: 'rule', message: `${operation} operation denied to path "${path}" by set rule`, rule, rulePath }; } } catch (err) { // If rule execution throws an exception, don't allow. Can happen when rule is "auth.uid === '...'", and auth is null because the user is not signed in return { allow: false, code: 'exception', message: `${operation} operation denied to path "${path}" by set rule`, rule, rulePath, details: err }; } } } if (isAllowed) { break; } // Proceed with next key in trail if (pathKeys.length === 0) { break; } let nextKey = pathKeys.shift(); currentPath = acebase_core_1.PathInfo.get(currentPath).childPath(nextKey); // if nextKey is '*' or '$something', rule[nextKey] will be undefined (or match a variable) so there is no // need to change things here for usage of wildcard paths in subscriptions if (typeof rule[nextKey] === 'undefined') { // Check if current rule has a wildcard child const wildcardKey = Object.keys(rule).find(key => key === '*' || key[0] === '$'); if (wildcardKey) { env[wildcardKey] = nextKey; env.vars[wildcardKey] = nextKey; } nextKey = wildcardKey; } nextKey && rulePathKeys.push(nextKey); rule = rule[nextKey]; } // Now dig deeper to check nested .validate rules if (isAllowed && ['set', 'update'].includes(operation) && !isPreFlight) { // validate rules start at current path being written to const startRule = pathInfo.keys.reduce((rule, key) => { if (typeof rule !== 'object' || rule === null) { return null; } if (key in rule) { return rule[key]; } if ('*' in rule) { return rule['*']; } const variableKey = Object.keys(rule).find(key => typeof key === 'string' && key.startsWith('$')); if (variableKey) { return rule[variableKey]; } return null; }, this.accessRules.rules); const getNestedRules = (target, rule) => { if (!rule) { return []; } const nested = Object.keys(rule).reduce((arr, key) => { if (key === '.validate' && ['string', 'function'].includes(typeof rule[key])) { arr.push({ target, validate: rule[key] }); } if (!key.startsWith('.')) { const nested = getNestedRules([...target, key], rule[key]); arr.push(...nested); } return arr; }, []); return nested; }; // Check all that apply for sent data (update requires a different strategy) const checkRules = getNestedRules([], startRule); for (const check of checkRules) { // Keep going as long as rules validate const targetData = check.target.reduce((data, key) => { if (data !== null && typeof data === 'object' && key in data) { return data[key]; } return null; }, data.value); if (typeof targetData === 'undefined' && operation === 'update' && check.target.length >= 1 && check.target[0] in data) { // Ignore, data for direct child path is not being set by update operation continue; } const validateData = typeof targetData === 'undefined' ? null : targetData; if (validateData === null) { // Do not validate deletes, this should be done by ".write" or ".delete" rule continue; } const validatePath = acebase_core_1.PathInfo.get(path).child(check.target).path; const validateEnv = Object.assign(Object.assign({}, env), { operation: operation === 'update' ? (check.target.length === 0 ? 'update' : 'set') : operation, data: validateData, exists: (target) => __awaiter(this, void 0, void 0, function* () { return this.db.ref(getFullPath(validatePath, target)).exists(); }), value: (target, include) => __awaiter(this, void 0, void 0, function* () { const snap = yield this.db.ref(getFullPath(validatePath, target)).get({ include }); return snap.val(); }) }); try { const result = yield (() => __awaiter(this, void 0, void 0, function* () { let result; if (typeof check.validate === 'function') { result = yield check.validate(validateEnv); } else if (typeof check.validate === 'string') { result = yield (0, sandbox_1.executeSandboxed)(check.validate, validateEnv); } else if (typeof check.validate === 'boolean') { result = check.validate ? 'allow' : 'deny'; } if (result === 'cascade') { this.debug.warn(`Rule at path ${validatePath} returned "cascade", but ${validateEnv.operation} rules always cascade`); } else if (!['cascade', 'deny', 'allow', true, false].includes(result)) { this.debug.warn(`${validateEnv.operation} rule for path ${validatePath} possibly returned an unintentional value (${JSON.stringify(result)}) which results in outcome "${result ? 'allow' : 'deny'}"`); } if (['cascade', 'deny', 'allow'].includes(result)) { return result; } return result ? 'allow' : 'deny'; }))(); if (result === 'deny') { return { allow: false, code: 'rule', message: `${operation} operation denied to path "${path}" by set rule`, rule: check.validate, rulePath: validatePath }; } } catch (err) { // If rule execution throws an exception, don't allow. Can happen when rule is "auth.uid === '...'", and auth is null because the user is not signed in return { allow: false, code: 'exception', message: `${operation} operation denied to path "${path}" by set rule`, rule: check.validate, rulePath: validatePath, details: err }; } } } return isAllowed ? allow : { allow: false, code: 'no_rule', message: `No rules set for requested path "${path}", defaulting to false` }; }); } add(rulePaths, ruleTypes, callback) { const paths = Array.isArray(rulePaths) ? rulePaths : [rulePaths]; const types = Array.isArray(ruleTypes) ? ruleTypes : [ruleTypes]; for (const path of paths) { const keys = acebase_core_1.PathInfo.getPathKeys(path); let target = this.accessRules.rules; for (const key of keys) { if (!(key in target)) { target[key] = {}; } target = target[key]; if (typeof target !== 'object' || target === null) { throw new Error(`Cannot add rule because value of key "${key}" is not an object`); } } for (const type of types) { target[`.${type}`] = callback; this.codeRules.push({ path, type, callback }); } } } } exports.PathBasedRules = PathBasedRules; //# sourceMappingURL=rules.js.map