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.
This commit is contained in:
Tomas Bures 2017-07-27 22:41:25 +03:00
parent 89256d62bd
commit 34823cf0cf
17 changed files with 352 additions and 146 deletions

104
access-control.md Normal file
View file

@ -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"]
```

1
app.js
View file

@ -329,7 +329,6 @@ if (app.get('env') === 'development') {
return next();
}
console.log(err);
if (req.needsJSONResponse) {
const resp = {
message: err.message,

View file

@ -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) {

View file

@ -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;

View file

@ -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) {
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");
let tdIdx = 1;
if (this.props.withDescription) {
const descHtml = ReactDOMServer.renderToStaticMarkup(<div>{node.data.description}</div>);
tdList.eq(tdIdx).html(descHtml);
tdIdx += 1;
}
if (this.props.actions) {
const linksContainer = jQuery('<span class="mt-action-links"/>');
const actions = this.props.actions(node.key);
const actions = this.props.actions(node);
for (const {label, link} of actions) {
const lnkHtml = ReactDOMServer.renderToStaticMarkup(<a href={link}>{label}</a>);
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 &&
<thead>
<tr>
<th>{t('Name')}</th>
<th className="mt-treetable-title">{t('Name')}</th>
{withDescription && <th>{t('Description')}</th>}
{actions && <th></th>}
</tr>
</thead>
@ -246,6 +260,7 @@ class TreeTable extends Component {
<tbody>
<tr>
<td></td>
{withDescription && <td></td>}
{actions && <td></td>}
</tr>
</tbody>

View file

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

View file

@ -16,16 +16,25 @@ export default class List extends Component {
render() {
const t = this.props.t;
const actions = key => [
{
const actions = node => {
const actions = [];
if (node.data.permissions.includes('edit')) {
actions.push({
label: 'Edit',
link: '/namespaces/edit/' + key
},
{
label: 'Share',
link: '/namespaces/share/' + key
link: '/namespaces/edit/' + node.key
});
}
];
if (node.data.permissions.includes('share')) {
actions.push({
label: 'Share',
link: '/namespaces/share/' + node.key
});
}
return actions;
};
return (
<div>
@ -35,7 +44,7 @@ export default class List extends Component {
<Title>{t('Namespaces')}</Title>
<TreeTable withHeader dataUrl="/rest/namespaces-tree" actions={actions} />
<TreeTable withHeader withDescription dataUrl="/rest/namespaces-tree" actions={actions} />
</div>
);
}

View file

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

View file

@ -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;

View file

@ -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,6 +86,7 @@ export default class CUD extends Component {
}
if (mailtrainConfig.isAuthMethodLocal) {
const email = state.getIn(['email', 'value']);
const emailServerValidation = state.getIn(['email', 'serverValidation']);
@ -128,6 +129,7 @@ export default class CUD extends Component {
state.setIn(['password', 'error'], passwordMsgs.length > 0 ? passwordMsgs : null);
state.setIn(['password2', 'error'], password !== password2 ? t('Passwords must match') : null);
}
validateNamespace(t, state);
}

View file

@ -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;
});
}
async function updateWithConsistencyCheck(context, entity) {
@ -95,7 +186,7 @@ async function remove(context, id) {
module.exports = {
hash,
list,
listTree,
getById,
create,
updateWithConsistencyCheck,

View file

@ -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;
});
}
async function updateWithConsistencyCheck(context, entity) {

View file

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

View file

@ -129,7 +129,11 @@ async function _rebuildPermissions(tx, restriction) {
}
// Change user 1 role to global role that has admin===true
// 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) {
@ -141,6 +145,7 @@ async function _rebuildPermissions(tx, restriction) {
if (adminRole) {
await tx('users').update('role', adminRole).where('id', 1 /* Admin user id */);
}
}
// Reset root and own namespace shares as per the user roles

View file

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

View file

@ -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) => {

View file

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