From 34823cf0cf78898e077d5a54cd4dae3c5f709fb7 Mon Sep 17 00:00:00 2001 From: Tomas Bures Date: Thu, 27 Jul 2017 22:41:25 +0300 Subject: [PATCH] Seeming working (though not very thoroughly tested) granular access control for reports, report templates and namespaces. Should work both in local auth case and LDAP auth case. --- access-control.md | 104 ++++++++++++++++++++++++++++++++++ app.js | 1 - client/src/lib/form.js | 5 +- client/src/lib/tree.css | 3 + client/src/lib/tree.js | 51 +++++++++++------ client/src/namespaces/CUD.js | 6 +- client/src/namespaces/List.js | 29 ++++++---- client/src/reports/List.js | 6 +- client/src/shares/Share.js | 11 ++-- client/src/users/CUD.js | 84 +++++++++++++-------------- models/namespaces.js | 103 +++++++++++++++++++++++++++++++-- models/report-templates.js | 8 ++- models/reports.js | 3 +- models/shares.js | 23 +++++--- models/users.js | 23 ++++---- routes/rest/account.js | 2 +- routes/rest/namespaces.js | 36 +----------- 17 files changed, 352 insertions(+), 146 deletions(-) create mode 100644 access-control.md diff --git a/access-control.md b/access-control.md new file mode 100644 index 00000000..8e99b6a1 --- /dev/null +++ b/access-control.md @@ -0,0 +1,104 @@ +## Access Control + +This document describes the key features and concepts of the current state of +access control in Mailtrain. + +The current state provides user management and granular access control to reports +and report templates. The user management supports both local authentication and +LDAP-based authentication. + +The access control has two abstractions levels: a high-level intended to be used through web UI, +and low-level, intended to be configured once through the Mailtrain config file. The high-level +layer serves for providing access to variuous resources, while the low-level layer is meant +to define the access roles in Mailtrain to reflect an organisational or process hierarchy. + +### High-level access management (through web UI) + +On the high abstraction level, which is accessible to users via the web-based UI, Mailtrain +recognizes different entities (reports, report templates, etc.) and user roles that regulate +access to these entities (e.g. role "reporter" that allows viewing a report but prevents editing +or deleting it). Access to entities is provided through so called "shares". A share is essentially +a triple: entity - role - user. + +Mailtrain further features hierarchical namespaces. Every entity has to reside in a namespace +(in reality, the namespace itself is an entity to which access can be given). + +While sharing an entity with a user gives the user access to the particular entity (in the +scope of the role), sharing a namespace amounts to giving access to all entities within +the namespace and transitively in all child namespaces. The role that regulates the access to the +particular namespaces further determines the access to all different entity types that can +reside in the namespace. + +To simplify the management of permissions, every user is associated with one global role and +a namespace. The global role regulates access to global resources and operations (i.e. those +things that are not associated with any namespace). An example of such a global operation is +rebuilding the permission cache. Further, the global role determines a default share of the +root namespace and the namespace of the user. For example, an administrator's global role may +specify that a user get administrator's role in the root namespace, which effectively gives +him/her access to everything. + +Mailtrain resets these default shares at start and also whenever permission cache is rebuilt +(essentially every time user, namespace or some entity is created or when share or user's + role is assigned). This effectively prevents deleting or overriding the default shares that + the user has through the global role. + + +### Low-level access management (through config file) + +Internally, Mailtrain relies on fine-grained permissions, which are triplets: +user - operation - entity (e.g. user id 1 - view - report id 2). These permissions are stored +in a permission cache (in DB) and automatically generated at startup and whenever the permissions + could have changed. + +Mailtrain's config file defines the roles (available in the high-level access management) and +specifies the mapping of roles to operations. + +The roles are potentially different for each entity type/scope (currently global, namespace, report, +report template). Each role defines the permitted operations for the given entity type/scope. +A namespace role further defines allowed operations for entity types within and under the + namespace. + +The following defines the role master for scope "global". This effectively means that in +"Create/Edit User" form, the user can be given role "Master". +The role gives the permission to rebuild the permission cache. Further, it specifies that the +holder of the role will automatically be given access (share) to the root namespace in the +namespace role "master". This role is also an admin role, which means that user id 1 will always be reset to this role. +This serves as a kind of bootstrap that makes sure that there is always a user that can be +used to give access to other users. +``` +[roles.global.master] +name="Master" +admin=true +description="All permissions" +permissions=["rebuildPermissions"] +rootNamespaceRole="master" +``` + +This defines the role "master" for "report" entities. It lists the operations that a user +that has "master" access to a particular report can do with the report. Note that to get the +"master" access to a particular report through this role, the report would either have to be shared with the user +with role "master". + +``` +[roles.report.master] +name="Master" +description="All permissions" +permissions=["view", "edit", "delete", "share", "execute", "viewContent", "viewOutput"] +``` + +The following defines the role "master" for "namespace" entities. Similarly to the example above, +it lists operations that relate to a namespace. In particular all "create" operations pertain +to a namespace rathen than to an entity, which at the time of creation does not exist yet. +Additionally, the namespace roles define permissions to all entity types under the namespace +(including child namespaces). +``` +[roles.namespace.master] +name="Master" +description="All permissions" +permissions=["view", "edit", "delete", "share", "createNamespace", "createReportTemplate", "createReport", "manageUsers"] + +[roles.namespace.master.children] +reportTemplate=["view", "edit", "delete", "share", "execute"] +report=["view", "edit", "delete", "share", "execute", "viewContent", "viewOutput"] +namespace=["view", "edit", "delete", "share", "createNamespace", "createReportTemplate", "createReport", "manageUsers"] +``` \ No newline at end of file diff --git a/app.js b/app.js index 426e15b1..64039a41 100644 --- a/app.js +++ b/app.js @@ -329,7 +329,6 @@ if (app.get('env') === 'development') { return next(); } - console.log(err); if (req.needsJSONResponse) { const resp = { message: err.message, diff --git a/client/src/lib/form.js b/client/src/lib/form.js index ae653ce7..35859da6 100644 --- a/client/src/lib/form.js +++ b/client/src/lib/form.js @@ -585,13 +585,14 @@ function withForm(target) { scheduleValidateForm(self); }) .catch(error => { - console.log('Ignoring unhandled error in "validateFormState": ' + error); + console.log('Error in "validateFormState": ' + error); self.setState(previousState => ({ formState: previousState.formState.set('isServerValidationRunning', false) })); - scheduleValidateForm(self); + // TODO: It might be good not to give up immediatelly, but retry a couple of times + // scheduleValidateForm(self); }); } else { if (formValidateResolve) { diff --git a/client/src/lib/tree.css b/client/src/lib/tree.css index 107a185c..a2baeac7 100644 --- a/client/src/lib/tree.css +++ b/client/src/lib/tree.css @@ -24,6 +24,9 @@ border-top: 0px none; } +.mt-treetable-container .mt-treetable-title { + min-width: 150px; +} .form-group .mt-treetable-container { border: 1px solid #cccccc; diff --git a/client/src/lib/tree.js b/client/src/lib/tree.js index bf68d921..49b4c993 100644 --- a/client/src/lib/tree.js +++ b/client/src/lib/tree.js @@ -49,13 +49,15 @@ class TreeTable extends Component { const response = await axios.get(dataUrl); const treeData = response.data; - treeData.expanded = true; - for (const child of treeData.children) { - child.expanded = true; + for (const root of treeData) { + root.expanded = true; + for (const child of root.children) { + child.expanded = true; + } } this.setState({ - treeData: [ response.data ] + treeData }); } @@ -66,7 +68,8 @@ class TreeTable extends Component { selection: PropTypes.oneOfType([PropTypes.array, PropTypes.string, PropTypes.number]), onSelectionChangedAsync: PropTypes.func, actions: PropTypes.func, - withHeader: PropTypes.bool + withHeader: PropTypes.bool, + withDescription: PropTypes.bool } componentWillReceiveProps(nextProps) { @@ -100,26 +103,35 @@ class TreeTable extends Component { }; let createNodeFn; - if (this.props.actions) { - createNodeFn = (event, data) => { - const node = data.node; - const tdList = jQuery(node.tr).find(">td"); + createNodeFn = (event, data) => { + const node = data.node; + const tdList = jQuery(node.tr).find(">td"); + let tdIdx = 1; + + if (this.props.withDescription) { + const descHtml = ReactDOMServer.renderToStaticMarkup(
{node.data.description}
); + tdList.eq(tdIdx).html(descHtml); + tdIdx += 1; + } + + if (this.props.actions) { const linksContainer = jQuery(''); - const actions = this.props.actions(node.key); + const actions = this.props.actions(node); for (const {label, link} of actions) { const lnkHtml = ReactDOMServer.renderToStaticMarkup({label}); const lnk = jQuery(lnkHtml); - lnk.click((evt) => { evt.preventDefault(); this.navigateTo(link) }); + lnk.click((evt) => { + evt.preventDefault(); + this.navigateTo(link) + }); linksContainer.append(lnk); } - - tdList.eq(1).html(linksContainer); - }; - } else { - createNodeFn = (event, data) => {}; - } + tdList.eq(tdIdx).html(linksContainer); + tdIdx += 1; + } + }; this.tree = jQuery(this.domTable).fancytree({ extensions: ['glyph', 'table'], @@ -220,6 +232,7 @@ class TreeTable extends Component { const props = this.props; const actions = props.actions; const withHeader = props.withHeader; + const withDescription = props.withDescription; let containerClass = 'mt-treetable-container'; if (this.selectMode === TreeSelectMode.NONE) { @@ -238,7 +251,8 @@ class TreeTable extends Component { {props.withHeader && - {t('Name')} + {t('Name')} + {withDescription && {t('Description')}} {actions && } @@ -246,6 +260,7 @@ class TreeTable extends Component { + {withDescription && } {actions && } diff --git a/client/src/namespaces/CUD.js b/client/src/namespaces/CUD.js index f60eb1e0..88def7e4 100644 --- a/client/src/namespaces/CUD.js +++ b/client/src/namespaces/CUD.js @@ -66,8 +66,10 @@ export default class CUD extends Component { axios.get('/rest/namespaces-tree') .then(response => { - response.data.expanded = true; - const data = [response.data]; + const data = response.data; + for (const root of data) { + root.expanded = true; + } if (this.props.edit && !this.isEditGlobal()) { this.removeNsIdSubtree(data); diff --git a/client/src/namespaces/List.js b/client/src/namespaces/List.js index c624dd37..e118524c 100644 --- a/client/src/namespaces/List.js +++ b/client/src/namespaces/List.js @@ -16,16 +16,25 @@ export default class List extends Component { render() { const t = this.props.t; - const actions = key => [ - { - label: 'Edit', - link: '/namespaces/edit/' + key - }, - { - label: 'Share', - link: '/namespaces/share/' + key + const actions = node => { + const actions = []; + + if (node.data.permissions.includes('edit')) { + actions.push({ + label: 'Edit', + link: '/namespaces/edit/' + node.key + }); } - ]; + + if (node.data.permissions.includes('share')) { + actions.push({ + label: 'Share', + link: '/namespaces/share/' + node.key + }); + } + + return actions; + }; return (
@@ -35,7 +44,7 @@ export default class List extends Component { {t('Namespaces')} - +
); } diff --git a/client/src/reports/List.js b/client/src/reports/List.js index e7433f87..a03a59cc 100644 --- a/client/src/reports/List.js +++ b/client/src/reports/List.js @@ -31,6 +31,10 @@ export default class List extends Component { entityTypeId: 'reportTemplate', requiredOperations: ['execute'] }, + createReportTemplate: { + entityTypeId: 'namespace', + requiredOperations: ['createReportTemplate'] + }, viewReportTemplate: { entityTypeId: 'reportTemplate', requiredOperations: ['view'] @@ -41,7 +45,7 @@ export default class List extends Component { this.setState({ createPermitted: result.data.createReport && result.data.executeReportTemplate, - templatesPermitted: result.data.viewReportTemplate + templatesPermitted: result.data.createReportTemplate || result.data.viewReportTemplate }); } diff --git a/client/src/shares/Share.js b/client/src/shares/Share.js index 2fbb9362..739e7b11 100644 --- a/client/src/shares/Share.js +++ b/client/src/shares/Share.js @@ -118,11 +118,12 @@ export default class Share extends Component { } ]; - const sharesColumns = [ - { data: 0, title: t('Username') }, - { data: 1, title: t('Name') }, - { data: 2, title: t('Role') } - ]; + const sharesColumns = []; + sharesColumns.push({ data: 0, title: t('Username') }); + if (mailtrainConfig.isAuthMethodLocal) { + sharesColumns.push({ data: 1, title: t('Name') }); + } + sharesColumns.push({ data: 2, title: t('Role') }); let usersLabelIndex = 1; diff --git a/client/src/users/CUD.js b/client/src/users/CUD.js index 02852545..470abb66 100644 --- a/client/src/users/CUD.js +++ b/client/src/users/CUD.js @@ -33,7 +33,7 @@ export default class CUD extends Component { this.initForm({ serverValidation: { url: '/rest/users-validate', - changed: ['username', 'email'], + changed: mailtrainConfig.isAuthMethodLocal ? ['username', 'email'] : ['username'], extra: ['id'] } }); @@ -86,49 +86,51 @@ export default class CUD extends Component { } - const email = state.getIn(['email', 'value']); - const emailServerValidation = state.getIn(['email', 'serverValidation']); + if (mailtrainConfig.isAuthMethodLocal) { + const email = state.getIn(['email', 'value']); + const emailServerValidation = state.getIn(['email', 'serverValidation']); - if (!email) { - state.setIn(['email', 'error'], t('Email must not be empty')); - } else if (!emailServerValidation || emailServerValidation.invalid) { - state.setIn(['email', 'error'], t('Invalid email address.')); - } else { - state.setIn(['email', 'error'], null); + if (!email) { + state.setIn(['email', 'error'], t('Email must not be empty')); + } else if (!emailServerValidation || emailServerValidation.invalid) { + state.setIn(['email', 'error'], t('Invalid email address.')); + } else { + state.setIn(['email', 'error'], null); + } + + + const name = state.getIn(['name', 'value']); + + if (!name) { + state.setIn(['name', 'error'], t('Full name must not be empty')); + } else { + state.setIn(['name', 'error'], null); + } + + + const password = state.getIn(['password', 'value']) || ''; + const password2 = state.getIn(['password2', 'value']) || ''; + + const passwordResults = this.passwordValidator.test(password); + + let passwordMsgs = []; + + if (!edit && !password) { + passwordMsgs.push(t('Password must not be empty')); + } + + if (password) { + passwordMsgs.push(...passwordResults.errors); + } + + if (passwordMsgs.length > 1) { + passwordMsgs = passwordMsgs.map((msg, idx) =>
{msg}
) + } + + state.setIn(['password', 'error'], passwordMsgs.length > 0 ? passwordMsgs : null); + state.setIn(['password2', 'error'], password !== password2 ? t('Passwords must match') : null); } - - const name = state.getIn(['name', 'value']); - - if (!name) { - state.setIn(['name', 'error'], t('Full name must not be empty')); - } else { - state.setIn(['name', 'error'], null); - } - - - const password = state.getIn(['password', 'value']) || ''; - const password2 = state.getIn(['password2', 'value']) || ''; - - const passwordResults = this.passwordValidator.test(password); - - let passwordMsgs = []; - - if (!edit && !password) { - passwordMsgs.push(t('Password must not be empty')); - } - - if (password) { - passwordMsgs.push(...passwordResults.errors); - } - - if (passwordMsgs.length > 1) { - passwordMsgs = passwordMsgs.map((msg, idx) =>
{msg}
) - } - - state.setIn(['password', 'error'], passwordMsgs.length > 0 ? passwordMsgs : null); - state.setIn(['password2', 'error'], password !== password2 ? t('Passwords must match') : null); - validateNamespace(t, state); } diff --git a/models/namespaces.js b/models/namespaces.js index 02aaf718..e3f48a78 100644 --- a/models/namespaces.js +++ b/models/namespaces.js @@ -5,11 +5,100 @@ const hasher = require('node-object-hash')(); const { enforce, filterObject } = require('../lib/helpers'); const interoperableErrors = require('../shared/interoperable-errors'); const shares = require('./shares'); +const permissions = require('../lib/permissions'); const allowedKeys = new Set(['name', 'description', 'namespace']); -async function list() { - return await knex('namespaces'); +async function listTree(context) { + // FIXME - process permissions + + const entityType = permissions.getEntityType('namespace'); + + // This builds a forest of namespaces that contains only those namespace that the user has access to + // This goes in three steps: 1) tree with all namespaces is built with parent-children links, 2) the namespaces that are not accessible + // by the user are pruned out, which potentially transforms the tree to a forest, 3) unneeded attributes (i.e. parent links) + // are removed and children are turned to an array are sorted alphabetically by name + + // Build a tree + const rows = await knex('namespaces') + .innerJoin(entityType.permissionsTable, { + [entityType.permissionsTable + '.entity']: 'namespaces.id', + [entityType.permissionsTable + '.user']: context.user.id + }) + .groupBy('namespaces.id') + .select([ + 'namespaces.id', 'namespaces.name', 'namespaces.description', 'namespaces.namespace', + knex.raw(`GROUP_CONCAT(${entityType.permissionsTable + '.operation'} SEPARATOR \';\') as permissions`) + ]); + + const entries = {}; + + for (let row of rows) { + let entry; + if (!entries[row.id]) { + entry = { + children: {} + }; + entries[row.id] = entry; + } else { + entry = entries[row.id]; + } + + if (row.namespace) { + if (!entries[row.namespace]) { + entries[row.namespace] = { + children: {} + }; + } + + entries[row.namespace].children[row.id] = entry; + entry.parent = entries[row.namespace]; + } else { + entry.parent = null; + } + + entry.key = row.id; + entry.title = row.name; + entry.description = row.description; + entry.permissions = row.permissions.split(';'); + } + + // Prune out the inaccessible namespaces + for (const entryId in entries) { + const entry = entries[entryId]; + + if (!entry.permissions.includes('view')) { + for (const childId in entry.children) { + const child = entry.children[childId]; + child.parent = entry.parent; + + if (entry.parent) { + entry.parent.children[childId] = child; + } + } + + if (entry.parent) { + delete entry.parent.children[entryId]; + } + + delete entries[entryId]; + } + } + + // Retrieve the roots before we discard the parent link + const roots = Object.values(entries).filter(x => x.parent === null); + + // Remove parent link, transform children to an array and sort it + for (const entryId in entries) { + const entry = entries[entryId]; + + entry.children = Object.values(entry.children); + entry.children.sort((x, y) => x.title.localeCompare(y.title)); + + delete entry.parent; + } + + return roots; } function hash(entity) { @@ -31,18 +120,20 @@ async function create(context, entity) { enforce(entity.namespace, 'Parent namespace must be set'); await shares.enforceEntityPermission(context, 'namespace', entity.namespace, 'createNamespace'); + let id; await knex.transaction(async tx => { if (!await tx('namespaces').select(['id']).where('id', entity.namespace).first()) { throw new interoperableErrors.DependencyNotFoundError(); } - const id = await tx('namespaces').insert(filterObject(entity, allowedKeys)); + const ids = await tx('namespaces').insert(filterObject(entity, allowedKeys)); + id = ids[0]; // We don't have to rebuild all entity types, because no entity can be a child of the namespace at this moment. await shares.rebuildPermissions(tx, { entityTypeId: 'namespace', entityId: id }); - - return id; }); + + return id; } async function updateWithConsistencyCheck(context, entity) { @@ -95,7 +186,7 @@ async function remove(context, id) { module.exports = { hash, - list, + listTree, getById, create, updateWithConsistencyCheck, diff --git a/models/report-templates.js b/models/report-templates.js index d43c037c..67231946 100644 --- a/models/report-templates.js +++ b/models/report-templates.js @@ -38,15 +38,17 @@ async function listDTAjax(context, params) { async function create(context, entity) { await shares.enforceEntityPermission(context, 'namespace', entity.namespace, 'createReportTemplate'); + let id; await knex.transaction(async tx => { await namespaceHelpers.validateEntity(tx, entity); - const id = await tx('report_templates').insert(filterObject(entity, allowedKeys)); + const ids = await tx('report_templates').insert(filterObject(entity, allowedKeys)); + id = ids[0]; await shares.rebuildPermissions(tx, { entityTypeId: 'reportTemplate', entityId: id }); - - return id; }); + + return id; } async function updateWithConsistencyCheck(context, entity) { diff --git a/models/reports.js b/models/reports.js index a9030b37..581c8a69 100644 --- a/models/reports.js +++ b/models/reports.js @@ -71,7 +71,8 @@ async function create(context, entity) { entity.params = JSON.stringify(entity.params); - id = await tx('reports').insert(filterObject(entity, allowedKeys)); + const ids = await tx('reports').insert(filterObject(entity, allowedKeys)); + id = ids[0]; await shares.rebuildPermissions(tx, { entityTypeId: 'report', entityId: id }); }); diff --git a/models/shares.js b/models/shares.js index 4589e052..b6af2e37 100644 --- a/models/shares.js +++ b/models/shares.js @@ -129,17 +129,22 @@ async function _rebuildPermissions(tx, restriction) { } - // Change user 1 role to global role that has admin===true - let adminRole; - for (const role in config.roles.global) { - if (config.roles.global[role].admin) { - adminRole = role; - break; + // To prevent users locking out themselves, we consider user with id 1 to be the admin and always assign it + // the admin role. The admin role is a global role that has admin===true + // If this behavior is not desired, it is enough to delete the user with id 1. + const adminUser = await tx('users').where('id', 1 /* Admin user id */).first(); + if (adminUser) { + let adminRole; + for (const role in config.roles.global) { + if (config.roles.global[role].admin) { + adminRole = role; + break; + } } - } - if (adminRole) { - await tx('users').update('role', adminRole).where('id', 1 /* Admin user id */); + if (adminRole) { + await tx('users').update('role', adminRole).where('id', 1 /* Admin user id */); + } } diff --git a/models/users.js b/models/users.js index 3100f8e5..0f657f43 100644 --- a/models/users.js +++ b/models/users.js @@ -173,15 +173,13 @@ async function create(context, user) { await shares.enforceEntityPermission(context, 'namespace', user.namespace, 'manageUsers'); } + let id; await knex.transaction(async tx => { if (passport.isAuthMethodLocal) { await _validateAndPreprocess(tx, user, true); - const userId = await tx('users').insert(filterObject(user, allowedKeys)); - - await shares.rebuildPermissions(tx, { userId }); - - return userId; + const ids = await tx('users').insert(filterObject(user, allowedKeys)); + id = ids[0]; } else { const filteredUser = filterObject(user, allowedKeysExternal); @@ -189,18 +187,19 @@ async function create(context, user) { await namespaceHelpers.validateEntity(tx, user); - const userId = await tx('users').insert(filteredUser); - - await shares.rebuildPermissions(tx, { userId }); - - return userId; + const ids = await tx('users').insert(filteredUser); + id = ids[0]; } + + await shares.rebuildPermissions(tx, { userId: id }); }); + + return id; } async function updateWithConsistencyCheck(context, user, isOwnAccount) { await knex.transaction(async tx => { - const existing = await tx('users').where(['id', 'namespace', 'role'], user.id).first(); + const existing = await tx('users').where('id', user.id).first(); if (!existing) { shares.throwPermissionDenied(); } @@ -226,7 +225,7 @@ async function updateWithConsistencyCheck(context, user, isOwnAccount) { await tx('users').where('id', user.id).update(filterObject(user, isOwnAccount ? ownAccountAllowedKeys : allowedKeys)); } else { - enforce(isOwnAccount, 'Local user management is required'); + enforce(!isOwnAccount, 'Local user management is required'); enforce(user.role in config.roles.global, 'Unknown role'); await namespaceHelpers.validateEntity(tx, user); diff --git a/routes/rest/account.js b/routes/rest/account.js index f585707f..94bb32ff 100644 --- a/routes/rest/account.js +++ b/routes/rest/account.js @@ -25,7 +25,7 @@ router.postAsync('/account-validate', passport.loggedIn, passport.csrfProtection const data = req.body; data.id = req.user.id; - return res.json(await users.serverValidate(data, true)); + return res.json(await users.serverValidate(req.context, data, true)); }); router.getAsync('/access-token', passport.loggedIn, async (req, res) => { diff --git a/routes/rest/namespaces.js b/routes/rest/namespaces.js index dc0ab9f5..a2151272 100644 --- a/routes/rest/namespaces.js +++ b/routes/rest/namespaces.js @@ -35,41 +35,9 @@ router.deleteAsync('/namespaces/:nsId', passport.loggedIn, passport.csrfProtecti router.getAsync('/namespaces-tree', passport.loggedIn, async (req, res) => { - // FIXME - process permissions + const tree = await namespaces.listTree(req.context); - const entries = {}; - let root; // Only the Root namespace is without a parent - const rows = await namespaces.list(); - - for (let row of rows) { - let entry; - if (!entries[row.id]) { - entry = { - children: [] - }; - entries[row.id] = entry; - } else { - entry = entries[row.id]; - } - - if (row.namespace) { - if (!entries[row.namespace]) { - entries[row.namespace] = { - children: [] - }; - } - - entries[row.namespace].children.push(entry); - - } else { - root = entry; - } - - entry.title = row.name; - entry.key = row.id; - } - - return res.json(root); + return res.json(tree); });