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);
});