WiP on permissions

Doesn't run. This commit is just to backup the changes.
This commit is contained in:
Tomas Bures 2017-07-26 22:42:05 +03:00
parent 5df444f641
commit 89c9615592
37 changed files with 913 additions and 366 deletions

8
app.js
View file

@ -19,6 +19,7 @@ const handlebarsHelpers = require('./lib/handlebars-helpers');
const compression = require('compression');
const passport = require('./lib/passport');
const tools = require('./lib/tools');
const contextHelpers = require('./lib/context-helpers');
const routes = require('./routes/index');
const lists = require('./routes/lists');
@ -220,11 +221,8 @@ app.use((req, res, next) => {
});
app.use((req, res, next) => {
req.context = {
user: req.user
};
next();
req.context = contextHelpers.getRequestContext(req);
next();
});
app.use('/', routes);

View file

@ -35,7 +35,7 @@ export default class CUD extends Component {
}
isEditGlobal() {
return this.state.entityId === 1;
return this.state.entityId === 1; /* Global namespace id */
}
isDelete() {

View file

@ -245,6 +245,8 @@ export default class CUD extends Component {
}
}
// FIXME - filter namespaces by permission
return (
<div>
{edit &&

View file

@ -2,7 +2,7 @@
import React, { Component } from 'react';
import { translate } from 'react-i18next';
import { withPageHelpers, Title, Toolbar, NavButton } from '../lib/page';
import { requiresAuthenticatedUser, withPageHelpers, Title, Toolbar, NavButton } from '../lib/page';
import { Table } from '../lib/table';
import { withErrorHandling, withAsyncErrorHandler } from '../lib/error-handling';
import moment from 'moment';
@ -12,7 +12,42 @@ import { ReportState } from '../../../shared/reports';
@translate()
@withErrorHandling
@withPageHelpers
@requiresAuthenticatedUser
export default class List extends Component {
constructor(props) {
super(props);
this.state = {};
}
@withAsyncErrorHandler
async fetchPermissions() {
const request = {
createReport: {
entityTypeId: 'namespace',
requiredOperations: ['createReport']
},
executeReportTemplate: {
entityTypeId: 'reportTemplate',
requiredOperations: ['execute']
},
viewReportTemplate: {
entityTypeId: 'reportTemplate',
requiredOperations: ['view']
},
};
const result = await axios.post('/rest/permissions-check', request);
this.setState({
createPermitted: result.data.createReport && result.data.executeReportTemplate,
templatesPermitted: result.data.viewReportTemplate
});
}
componentDidMount() {
this.fetchPermissions();
}
@withAsyncErrorHandler
async stop(table, id) {
@ -30,14 +65,18 @@ export default class List extends Component {
const t = this.props.t;
const actions = data => {
let view, startStop, refreshTimeout;
const actions = [];
const perms = data[8];
const permsReportTemplate = data[9];
let viewContent, startStop, refreshTimeout;
const state = data[6];
const id = data[0];
const mimeType = data[7];
if (state === ReportState.PROCESSING || state === ReportState.SCHEDULED) {
view = {
viewContent = {
label: <span className="glyphicon glyphicon-hourglass" aria-hidden="true" title="Processing"></span>,
};
@ -49,12 +88,12 @@ export default class List extends Component {
refreshTimeout = 1000;
} else if (state === ReportState.FINISHED) {
if (mimeType === 'text/html') {
view = {
viewContent = {
label: <span className="glyphicon glyphicon-eye-open" aria-hidden="true" title="View"></span>,
link: `reports/view/${id}`
};
} else if (mimeType === 'text/csv') {
view = {
viewContent = {
label: <span className="glyphicon glyphicon-download-alt" aria-hidden="true" title="Download"></span>,
href: `reports/download/${id}`
};
@ -66,7 +105,7 @@ export default class List extends Component {
};
} else if (state === ReportState.FAILED) {
view = {
viewContent = {
label: <span className="glyphicon glyphicon-thumbs-down" aria-hidden="true" title="Report generation failed"></span>,
};
@ -76,25 +115,38 @@ export default class List extends Component {
};
}
return {
refreshTimeout,
actions: [
view,
if (perms.includes('viewContent')) {
actions.push(viewContent);
}
if (perms.includes('viewOutput')) {
actions.push(
{
label: <span className="glyphicon glyphicon-modal-window" aria-hidden="true" title="View console output"></span>,
link: `reports/output/${id}`
},
startStop,
{
label: <span className="glyphicon glyphicon-wrench" aria-hidden="true" title="Edit"></span>,
link: `/reports/edit/${id}`
},
{
label: <span className="glyphicon glyphicon-share" aria-hidden="true" title="Share"></span>,
link: `/reports/share/${id}`
}
]
};
);
}
if (perms.includes('execute') && permsReportTemplate.includes('execute')) {
actions.push(startStop);
}
if (perms.includes('edit') && permsReportTemplate.includes('execute')) {
actions.push({
label: <span className="glyphicon glyphicon-edit" aria-hidden="true" title="Edit"></span>,
link: `/reports/edit/${id}`
});
}
if (perms.includes('share')) {
actions.push({
label: <span className="glyphicon glyphicon-share-alt" aria-hidden="true" title="Share"></span>,
link: `/reports/share/${id}`
});
}
return { refreshTimeout, actions };
};
const columns = [
@ -106,11 +158,16 @@ export default class List extends Component {
{ data: 5, title: t('Namespace') }
];
return (
<div>
<Toolbar>
<NavButton linkTo="/reports/create" className="btn-primary" icon="plus" label={t('Create Report')}/>
<NavButton linkTo="/reports/templates" className="btn-primary" label={t('Report Templates')}/>
{this.state.createPermitted &&
<NavButton linkTo="/reports/create" className="btn-primary" icon="plus" label={t('Create Report')}/>
}
{this.state.templatesPermitted &&
<NavButton linkTo="/reports/templates" className="btn-primary" label={t('Report Templates')}/>
}
</Toolbar>
<Title>{t('Reports')}</Title>

View file

@ -3,7 +3,7 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { translate, Trans } from 'react-i18next';
import { withPageHelpers, Title } from '../../lib/page'
import { requiresAuthenticatedUser, withPageHelpers, Title } from '../../lib/page'
import { withForm, Form, FormSendMethod, InputField, TextArea, Dropdown, ACEEditor, ButtonRow, Button } from '../../lib/form';
import axios from '../../lib/axios';
import { withErrorHandling, withAsyncErrorHandler } from '../../lib/error-handling';
@ -14,6 +14,7 @@ import { validateNamespace, NamespaceSelect } from '../../lib/namespace';
@withForm
@withPageHelpers
@withErrorHandling
@requiresAuthenticatedUser
export default class CUD extends Component {
constructor(props) {
super(props);
@ -300,6 +301,8 @@ export default class CUD extends Component {
const t = this.props.t;
const edit = this.props.edit;
// FIXME - filter namespaces by permission
return (
<div>
{edit &&

View file

@ -4,30 +4,65 @@ import React, { Component } from 'react';
import { translate } from 'react-i18next';
import { DropdownMenu } from '../../lib/bootstrap-components';
import { requiresAuthenticatedUser, withPageHelpers, Title, Toolbar, DropdownLink } from '../../lib/page';
import { withErrorHandling, withAsyncErrorHandler } from '../../lib/error-handling';
import { Table } from '../../lib/table';
import axios from '../../lib/axios';
import moment from 'moment';
@translate()
@withPageHelpers
@withErrorHandling
@requiresAuthenticatedUser
export default class List extends Component {
constructor(props) {
super(props);
this.state = {};
}
@withAsyncErrorHandler
async fetchPermissions() {
const request = {
createReportTemplate: {
entityTypeId: 'namespace',
requiredOperations: ['createReportTemplate']
}
};
const result = await axios.post('/rest/permissions-check', request);
this.setState({
createPermitted: result.data.createReportTemplate
});
}
componentDidMount() {
this.fetchPermissions();
}
render() {
const t = this.props.t;
const actions = data => [
{
label: 'Edit',
link: '/reports/templates/edit/' + data[0]
},
{
label: 'Share',
link: '/reports/templates/share/' + data[0]
const actions = data => {
const actions = [];
const perms = data[5];
if (perms.includes('view')) {
actions.push({
label: <span className="glyphicon glyphicon-edit" aria-hidden="true" title="Edit"></span>,
link: '/reports/templates/edit/' + data[0]
});
}
];
if (perms.includes('share')) {
actions.push({
label: <span className="glyphicon glyphicon-share-alt" aria-hidden="true" title="Share"></span>,
link: '/reports/templates/share/' + data[0]
});
}
return actions;
};
const columns = [
{ data: 0, title: "#" },
@ -39,14 +74,16 @@ export default class List extends Component {
return (
<div>
<Toolbar>
<DropdownMenu className="btn-primary" label={t('Create Report Template')}>
<DropdownLink to="/reports/templates/create">{t('Blank')}</DropdownLink>
<DropdownLink to="/reports/templates/create/subscribers-all">{t('All Subscribers')}</DropdownLink>
<DropdownLink to="/reports/templates/create/subscribers-grouped">{t('Grouped Subscribers')}</DropdownLink>
<DropdownLink to="/reports/templates/create/export-list-csv">{t('Export List as CSV')}</DropdownLink>
</DropdownMenu>
</Toolbar>
{this.state.createPermitted &&
<Toolbar>
<DropdownMenu className="btn-primary" label={t('Create Report Template')}>
<DropdownLink to="/reports/templates/create">{t('Blank')}</DropdownLink>
<DropdownLink to="/reports/templates/create/subscribers-all">{t('All Subscribers')}</DropdownLink>
<DropdownLink to="/reports/templates/create/subscribers-grouped">{t('Grouped Subscribers')}</DropdownLink>
<DropdownLink to="/reports/templates/create/export-list-csv">{t('Export List as CSV')}</DropdownLink>
</DropdownMenu>
</Toolbar>
}
<Title>{t('Report Templates')}</Title>

View file

@ -4,12 +4,11 @@ import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { translate } from 'react-i18next';
import { requiresAuthenticatedUser, withPageHelpers, Title } from '../lib/page';
import { withForm, Form, FormSendMethod, InputField, ButtonRow, Button, TreeTableSelect } from '../lib/form';
import { withForm, Form, FormSendMethod, InputField, ButtonRow, Button, TableSelect } from '../lib/form';
import axios from '../lib/axios';
import { withErrorHandling, withAsyncErrorHandler } from '../lib/error-handling';
import interoperableErrors from '../../../shared/interoperable-errors';
import passwordValidator from '../../../shared/password-validator';
import validators from '../../../shared/validators';
import { ModalDialog } from '../lib/bootstrap-components';
import mailtrainConfig from 'mailtrainConfig';
import { validateNamespace, NamespaceSelect } from '../lib/namespace';
@ -25,7 +24,9 @@ export default class CUD extends Component {
this.passwordValidator = passwordValidator(props.t);
this.state = {};
this.state = {
globalRoles: []
};
if (props.edit) {
this.state.entityId = parseInt(props.match.params.id);
@ -48,6 +49,14 @@ export default class CUD extends Component {
return this.props.match.params.action === 'delete';
}
@withAsyncErrorHandler
async fetchGlobalRoles() {
const result = await axios.get('/rest/users-global-roles');
this.setState({
globalRoles: result.data
});
}
@withAsyncErrorHandler
async loadFormValues() {
await this.getFormValuesFromURL(`/rest/users/${this.state.entityId}`, data => {
@ -80,8 +89,6 @@ export default class CUD extends Component {
if (!username) {
state.setIn(['username', 'error'], t('User name must not be empty'));
} else if (!validators.usernameValid(username)) {
state.setIn(['username', 'error'], t('User name may contain only the following characters: A-Z, a-z, 0-9, "_", "-", "." and may start only with A-Z, a-z, 0-9.'));
} else if (!usernameServerValidation || usernameServerValidation.exists) {
state.setIn(['username', 'error'], t('The user name already exists in the system.'));
} else {
@ -214,6 +221,20 @@ export default class CUD extends Component {
const userId = this.getFormValue('id');
const canDelete = userId !== 1 && mailtrainConfig.userId !== userId;
const roles = mailtrainConfig.roles.global;
const rolesColumns = [
{ data: 1, title: "Name" },
{ data: 2, title: "Description" },
];
const rolesData = [];
for (const key in roles) {
const role = roles[key];
rolesData.push([ key, role.name, role.description ]);
}
return (
<div>
{edit && canDelete &&
@ -229,10 +250,15 @@ export default class CUD extends Component {
<Form stateOwner={this} onSubmitAsync={::this.submitHandler}>
<InputField id="username" label={t('User Name')}/>
<InputField id="name" label={t('Full Name')}/>
<InputField id="email" label={t('Email')}/>
<InputField id="password" label={t('Password')} type="password" />
<InputField id="password2" label={t('Repeat Password')} type="password" />
{mailtrainConfig.isAuthMethodLocal &&
<div>
<InputField id="name" label={t('Full Name')}/>
<InputField id="email" label={t('Email')}/>
<InputField id="password" label={t('Password')} type="password"/>
<InputField id="password2" label={t('Repeat Password')} type="password"/>
</div>
}
<TableSelect id="role" label={t('Role')} withHeader dropdown data={rolesData} columns={rolesColumns} selectionLabelIndex={1}/>
<NamespaceSelect/>
<ButtonRow>

View file

@ -17,7 +17,12 @@ export default class List extends Component {
render() {
const t = this.props.t;
let actions;
const actions = data => [
{
label: 'Edit',
link: '/users/edit/' + data[0]
}
];
const columns = [
{ data: 0, title: "#" },
@ -26,13 +31,6 @@ export default class List extends Component {
if (mailtrainConfig.isAuthMethodLocal) {
columns.push({ data: 2, title: "Full Name" });
actions = data => [
{
label: 'Edit',
link: '/users/edit/' + data[0]
}
];
}
columns.push({ data: 3, title: "Namespace" });

View file

@ -122,6 +122,9 @@ uidTag="username"
# nameTag identifies the attribute to be used for user's full name
nameTag="username"
passwordresetlink=""
newUserRole="master"
# Global namespace id
newUserNamespaceId=1
[postfixbounce]
# Enable to allow writing Postfix bounce log to Mailtrain listener
@ -188,28 +191,72 @@ logger=false
browser="phantomjs"
[roles.global.master]
name="Master"
admin=true
description="All permissions"
permissions=["rebuildPermissions"]
rootNamespaceRole="master"
[roles.reportTemplate.master]
name="Master"
description="All permissions"
permissions=["view", "edit", "delete"]
permissions=["view", "edit", "delete", "share", "execute"]
[roles.report.master]
name="Master"
description="All permissions"
permissions=["view", "edit", "delete"]
permissions=["view", "edit", "delete", "share", "execute", "viewContent", "viewOutput"]
[roles.list.master]
name="Master"
description="All permissions"
permissions=["view"]
permissions=[]
[roles.namespace.master]
name="Master"
description="All permissions"
permissions=["view", "edit", "create", "delete", "create list"]
permissions=["view", "edit", "delete", "share", "createNamespace", "createReportTemplate", "createReport", "manageUsers"]
[roles.namespace.master.childperms]
reportTemplate=["view", "edit", "delete"]
report=["view", "edit", "delete"]
list=["view"]
namespace=["view", "edit", "create", "delete", "create list"]
[roles.namespace.master.children]
reportTemplate=["view", "edit", "delete", "share", "execute"]
report=["view", "edit", "delete", "share", "execute", "viewContent", "viewOutput"]
list=[]
namespace=["view", "edit", "delete", "share", "createNamespace", "createReportTemplate", "createReport", "manageUsers"]
[roles.global.editor]
name="Editor"
description="Anything under own namespace except operations related to sending and doing reports"
permissions=[]
ownNamespaceRole="editor"
[roles.reportTemplate.editor]
name="Editor"
description="Anything under own namespace except operations related to sending and doing reports"
permissions=[]
[roles.report.editor]
name="Editor"
description="Anything under own namespace except operations related to sending and doing reports"
permissions=["view", "viewContent", "viewOutput"]
[roles.list.editor]
name="Editor"
description="Anything under own namespace except operations related to sending and doing reports"
permissions=[]
[roles.namespace.editor]
name="Editor"
description="Anything under own namespace except operations related to sending and doing reports"
permissions=["view", "edit", "delete"]
[roles.namespace.editor.children]
reportTemplate=[]
report=["view", "viewContent", "viewOutput"]
list=[]
namespace=["view", "edit", "delete"]

View file

@ -21,6 +21,8 @@ const senders = require('./lib/senders');
const reportProcessor = require('./lib/report-processor');
const executor = require('./lib/executor');
const privilegeHelpers = require('./lib/privilege-helpers');
const knex = require('./lib/knex');
const shares = require('./models/shares');
let port = config.www.port;
let host = config.www.host;
@ -38,19 +40,6 @@ app.set('port', port);
let server = http.createServer(app);
// Check if database needs upgrading before starting the server
dbcheck(err => {
if (err) {
log.error('DB', err.message || err);
return process.exit(1);
}
/**
* Listen on provided port, on all network interfaces.
*/
server.listen(port, host);
});
server.on('error', err => {
if (err.syscall !== 'listen') {
throw err;
@ -145,3 +134,20 @@ server.on('listening', () => {
startNextServices();
}
});
// Check if database needs upgrading before starting the server
// First, the legacy migration
dbcheck(err => {
if (err) {
log.error('DB', err.message || err);
return process.exit(1);
}
// And now the current migration with Knex
knex.migrate.latest()
.then(() => shares.rebuildPermissions())
.then(() => server.listen(port, host)); // Listen on provided port, on all network interfaces.
});

29
lib/context-helpers.js Normal file
View file

@ -0,0 +1,29 @@
'use strict';
const knex = require('../lib/knex');
function getRequestContext(req) {
const context = {
user: req.user
};
return context;
}
function getServiceContext() {
const context = {
user: {
id: 1,
username: 'admin',
name: 'Service worker',
email: ''
}
};
return context;
}
module.exports = {
getRequestContext,
getServiceContext
};

View file

@ -1,36 +1,64 @@
'use strict';
const knex = require('../lib/knex');
const permissions = require('../lib/permissions');
async function ajaxList(params, queryFun, columns) {
async function ajaxList(params, queryFun, columns, mapFun) {
return await knex.transaction(async (tx) => {
const query = queryFun(tx);
const columnsNames = [];
const columnsSelect = [];
for (const col of columns) {
if (typeof col === 'string') {
columnsNames.push(col);
columnsSelect.push(col);
} else {
columnsNames.push(col.name);
if (col.raw) {
columnsSelect.push(tx.raw(col.raw));
} else if (col.query) {
columnsSelect.push(function () { return col.query(this); });
}
}
}
if (params.operation === 'getBy') {
query.whereIn(columns[parseInt(params.column)], params.values);
query.select(columns);
const query = queryFun(tx);
query.whereIn(columnsNames[parseInt(params.column)], params.values);
query.select(columnsSelect);
const rows = await query;
const rowsOfArray = rows.map(row => Object.keys(row).map(key => row[key]));
return rowsOfArray;
} else {
const recordsTotalQuery = query.clone().count('* as recordsTotal').first();
const recordsTotal = (await recordsTotalQuery).recordsTotal;
query.where(function() {
const whereFun = function() {
let searchVal = '%' + params.search.value.replace(/\\/g, '\\\\').replace(/([%_])/g, '\\$1') + '%';
for (let colIdx = 0; colIdx < params.columns.length; colIdx++) {
const col = params.columns[colIdx];
if (col.searchable) {
this.orWhere(columns[parseInt(col.data)], 'like', searchVal);
this.orWhere(columnsNames[parseInt(col.data)], 'like', searchVal);
}
}
});
}
const recordsFilteredQuery = query.clone().count('* as recordsFiltered').first();
/* There are a few SQL peculiarities that make this query a bit weird:
- Group by (which is used in getting permissions) don't go well with count(*). Thus we run the actual query
as a sub-query and then count the number of results.
- SQL does not like if it have columns with the same name in the subquery. This happens multiple tables are joined.
To circumvent this, we select only the first column (whatever it is). Since this is not "distinct", it is supposed
to give us the right number of rows anyway.
*/
const recordsTotalQuery = tx.count('* as recordsTotal').from(function () { return queryFun(this).select(columnsSelect[0]).as('records'); }).first();
const recordsTotal = (await recordsTotalQuery).recordsTotal;
const recordsFilteredQuery = tx.count('* as recordsFiltered').from(function () { return queryFun(this).select(columnsSelect[0]).where(whereFun).as('records'); }).first();
const recordsFiltered = (await recordsFilteredQuery).recordsFiltered;
const query = queryFun(tx);
query.where(whereFun);
query.offset(parseInt(params.start));
const limit = parseInt(params.length);
@ -38,16 +66,25 @@ async function ajaxList(params, queryFun, columns) {
query.limit(limit);
}
query.select(columns);
query.select(columnsSelect);
for (const order of params.order) {
query.orderBy(columns[params.columns[order.column].data], order.dir);
query.orderBy(columnsNames[params.columns[order.column].data], order.dir);
}
query.options({rowsAsArray:true});
const rows = await query;
const rowsOfArray = rows.map(row => Object.keys(row).map(field => row[field]));
const rowsOfArray = rows.map(row => {
const arr = Object.keys(row).map(field => row[field]);
if (mapFun) {
const result = mapFun(arr);
return result || arr;
} else {
return arr;
}
});
const result = {
draw: params.draw,
@ -61,6 +98,51 @@ async function ajaxList(params, queryFun, columns) {
});
}
async function ajaxListWithPermissions(context, fetchSpecs, params, queryFun, columns) {
const permCols = [];
for (const fetchSpec of fetchSpecs) {
const entityType = permissions.getEntityType(fetchSpec.entityTypeId);
permCols.push({
name: `permissions_${fetchSpec.entityTypeId}`,
query: builder => builder
.from(entityType.permissionsTable)
.select(knex.raw('GROUP_CONCAT(operation SEPARATOR \';\')'))
.whereRaw(`${entityType.permissionsTable}.entity = ${entityType.entitiesTable}.id`)
.where(`${entityType.permissionsTable}.user`, context.user.id)
.as(`permissions_${fetchSpec.entityTypeId}`)
});
}
return await ajaxList(
params,
builder => {
let query = queryFun(builder);
for (const fetchSpec of fetchSpecs) {
const entityType = permissions.getEntityType(fetchSpec.entityTypeId);
query = query.innerJoin(
function () {
return this.from(entityType.permissionsTable).select('entity').where('user', context.user.id).whereIn('operation', fetchSpec.requiredOperations).as(`permitted__${fetchSpec.entityTypeId}`);
},
`permitted__${fetchSpec.entityTypeId}.entity`, `${entityType.entitiesTable}.id`)
}
return query;
},
[
...columns,
...permCols
],
data => {
for (let idx = 0; idx < fetchSpecs.length; idx++) {
data[columns.length + idx] = data[columns.length + idx].split(';');
}
}
);
}
module.exports = {
ajaxList
ajaxList,
ajaxListWithPermissions
};

View file

@ -11,6 +11,4 @@ const knex = require('knex')({
// , debug: true
});
knex.migrate.latest();
module.exports = knex;

View file

@ -1,15 +1,15 @@
'use strict';
let config = require('config');
let log = require('npmlog');
let _ = require('./translate')._;
let util = require('util');
const config = require('config');
const log = require('npmlog');
const _ = require('./translate')._;
const util = require('util');
let passport = require('passport');
let LocalStrategy = require('passport-local').Strategy;
const passport = require('passport');
const LocalStrategy = require('passport-local').Strategy;
let csrf = require('csurf');
let bodyParser = require('body-parser');
const csrf = require('csurf');
const bodyParser = require('body-parser');
const users = require('../models/users');
const { nodeifyFunction, nodeifyPromise } = require('./nodeify');
@ -104,20 +104,24 @@ if (config.ldap.enabled && LdapStrategy) {
id: user.id,
username: user.username,
name: profile[config.ldap.nameTag],
email: profile.mail
email: profile.mail,
role: user.role
};
} catch (err) {
if (err instanceof interoperableErrors.NotFoundError) {
const userId = await users.createExternal({
const userId = await users.create({
username: profile[config.ldap.uidTag],
role: config.ldap.newUserRole,
namespace: config.ldap.newUserNamespaceId
});
return {
id: userId,
username: profile[config.ldap.uidTag],
name: profile[config.ldap.nameTag],
email: profile.mail
email: profile.mail,
role: config.ldap.newUserRole
};
} else {
throw err;
@ -139,6 +143,6 @@ if (config.ldap.enabled && LdapStrategy) {
})));
passport.serializeUser((user, done) => done(null, user.id));
passport.deserializeUser((id, done) => nodeifyPromise(users.getById(id), done));
passport.deserializeUser((id, done) => nodeifyPromise(users.getByIdNoPerms(id), done));
}

View file

@ -1,75 +1,38 @@
'use strict';
const config = require('config');
// FIXME - redo or delete
/*
class ReportTemplatePermission {
constructor(name) {
this.name = name;
this.entityType = 'report-template';
}
}
const ReportTemplatePermissions = {
View: new ReportTemplatePermission('view'),
Edit: new ReportTemplatePermission('edit'),
Delete: new ReportTemplatePermission('delete')
};
class ListPermission {
constructor(name) {
this.name = name;
this.entityType = 'list';
const entityTypes = {
namespace: {
entitiesTable: 'namespaces',
sharesTable: 'shares_namespace',
permissionsTable: 'permissions_namespace'
},
report: {
entitiesTable: 'reports',
sharesTable: 'shares_report',
permissionsTable: 'permissions_report'
},
reportTemplate: {
entitiesTable: 'report_templates',
sharesTable: 'shares_report_template',
permissionsTable: 'permissions_report_template'
}
}
const ListPermissions = {
View: new ListPermissions('view')
};
class NamespacePermission {
constructor(name) {
this.name = name;
this.entityType = 'namespace';
}
function getEntityTypes() {
return entityTypes;
}
const NamespacePermissions = {
View: new NamespacePermission('view'),
Edit: new NamespacePermission('edit'),
Create: new NamespacePermission('create'),
Delete: new NamespacePermission('delete'),
CreateList: new NamespacePermission('create list')
};
*/
function getEntityType(entityTypeId) {
const entityType = entityTypes[entityTypeId];
/*
async function can(context, operation, entityId) {
if (!context.user) {
return false;
if (!entityType) {
throw new Error(`Unknown entity type ${entityTypeId}`);
}
const result = await knex('permissions_' + operation.entityType).select(['id']).where({
entity: entityId,
user: context.user.id,
operation: operation.name
}).first();
return !!result;
return entityType
}
async function buildPermissions() {
}
can(ctx, ListPermissions.View, 3)
can(ctx, NamespacePermissions.CreateList, 2)
can(ctx, ReportTemplatePermissions.ViewReport, 5)
*/
module.exports = {
getEntityTypes,
getEntityType
}

View file

@ -3,6 +3,7 @@
const log = require('npmlog');
const reports = require('../models/reports');
const executor = require('./executor');
const contextHelpers = require('../lib/context-helpers');
let runningWorkersCount = 0;
let maxWorkersCount = 1;
@ -99,7 +100,7 @@ async function tryStartWorkers() {
isStartingWorkers = false;
}
module.exports.start = async reportId => {
module.exports.start = async (reportId) => {
if (!workers[reportId]) {
log.info('ReportProcessor', 'Scheduling report id: %s', reportId);
await reports.updateFields(reportId, { state: reports.ReportState.SCHEDULED, last_run: null});

View file

@ -5,7 +5,7 @@ const dtHelpers = require('../lib/dt-helpers');
const interoperableErrors = require('../shared/interoperable-errors');
async function listDTAjax(params) {
return await dtHelpers.ajaxList(params, tx => tx('campaigns'), ['campaigns.id', 'campaigns.name', 'campaigns.description', 'campaigns.status', 'campaigns.created']);
return await dtHelpers.ajaxList(params, builder => builder.from('campaigns'), ['campaigns.id', 'campaigns.name', 'campaigns.description', 'campaigns.status', 'campaigns.created']);
}
async function getById(id) {

View file

@ -5,7 +5,7 @@ const dtHelpers = require('../lib/dt-helpers');
const interoperableErrors = require('../shared/interoperable-errors');
async function listDTAjax(params) {
return await dtHelpers.ajaxList(params, tx => tx('lists'), ['lists.id', 'lists.name', 'lists.cid', 'lists.subscribers', 'lists.description']);
return await dtHelpers.ajaxList(params, builder => builder.from('lists'), ['lists.id', 'lists.name', 'lists.cid', 'lists.subscribers', 'lists.description']);
}
async function getById(id) {

View file

@ -16,7 +16,9 @@ function hash(entity) {
return hasher.hash(filterObject(entity, allowedKeys));
}
async function getById(id) {
async function getById(context, id) {
await shares.enforceEntityPermission(context, 'namespace', id, 'view');
const entity = await knex('namespaces').where('id', id).first();
if (!entity) {
throw new interoperableErrors.NotFoundError();
@ -25,16 +27,17 @@ async function getById(id) {
return entity;
}
async function create(entity) {
await knex.transaction(async tx => {
const id = await tx('namespaces').insert(filterObject(entity, allowedKeys));
async function create(context, entity) {
enforce(entity.namespace, 'Parent namespace must be set');
await shares.enforceEntityPermission(context, 'namespace', entity.namespace, 'createNamespace');
if (entity.namespace) {
if (!await tx('namespaces').select(['id']).where('id', entity.namespace).first()) {
throw new interoperableErrors.DependencyNotFoundError();
}
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));
// 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 });
@ -42,12 +45,13 @@ async function create(entity) {
});
}
async function updateWithConsistencyCheck(entity) {
async function updateWithConsistencyCheck(context, entity) {
enforce(entity.id !== 1 || entity.namespace === null, 'Cannot assign a parent to the root namespace.');
await shares.enforceEntityPermission(context, 'namespace', entity.id, 'edit');
await knex.transaction(async tx => {
const existing = await tx('namespaces').where('id', entity.id).first();
if (!entity) {
if (!existing) {
throw new interoperableErrors.NotFoundError();
}
@ -75,8 +79,9 @@ async function updateWithConsistencyCheck(entity) {
});
}
async function remove(id) {
async function remove(context, id) {
enforce(id !== 1, 'Cannot delete the root namespace.');
await shares.enforceEntityPermission(context, 'namespace', id, 'delete');
await knex.transaction(async tx => {
const childNs = await tx('namespaces').where('namespace', id).first();

View file

@ -14,7 +14,9 @@ function hash(entity) {
return hasher.hash(filterObject(entity, allowedKeys));
}
async function getById(id) {
async function getById(context, id) {
await shares.enforceEntityPermission(context, 'reportTemplate', id, 'view');
const entity = await knex('report_templates').where('id', id).first();
if (!entity) {
throw new interoperableErrors.NotFoundError();
@ -23,15 +25,19 @@ async function getById(id) {
return entity;
}
async function listDTAjax(params) {
return await dtHelpers.ajaxList(
async function listDTAjax(context, params) {
return await dtHelpers.ajaxListWithPermissions(
context,
[{ entityTypeId: 'reportTemplate', requiredOperations: ['view'] }],
params,
tx => tx('report_templates').innerJoin('namespaces', 'namespaces.id', 'report_templates.namespace'),
['report_templates.id', 'report_templates.name', 'report_templates.description', 'report_templates.created', 'namespaces.name']
builder => builder.from('report_templates').innerJoin('namespaces', 'namespaces.id', 'report_templates.namespace'),
[ 'report_templates.id', 'report_templates.name', 'report_templates.description', 'report_templates.created', 'namespaces.name' ]
);
}
async function create(entity) {
async function create(context, entity) {
await shares.enforceEntityPermission(context, 'namespace', entity.namespace, 'createReportTemplate');
await knex.transaction(async tx => {
await namespaceHelpers.validateEntity(tx, entity);
@ -43,10 +49,12 @@ async function create(entity) {
});
}
async function updateWithConsistencyCheck(entity) {
async function updateWithConsistencyCheck(context, entity) {
await shares.enforceEntityPermission(context, 'reportTemplate', entity.id, 'edit');
await knex.transaction(async tx => {
const existing = await tx('report_templates').where('id', entity.id).first();
if (!entity) {
if (!existing) {
throw new interoperableErrors.NotFoundError();
}
@ -57,17 +65,26 @@ async function updateWithConsistencyCheck(entity) {
await namespaceHelpers.validateEntity(tx, entity);
if (existing.namespace !== entity.namespace) {
await shares.enforceEntityPermission(context, 'namespace', entity.namespace, 'createReport');
await shares.enforceEntityPermission(context, 'reportTemplate', entity.id, 'delete');
}
await tx('report_templates').where('id', entity.id).update(filterObject(entity, allowedKeys));
await shares.rebuildPermissions(tx, { entityTypeId: 'reportTemplate', entityId: entity.id });
});
}
async function remove(id) {
async function remove(context, id) {
await shares.enforceEntityPermission(context, 'reportTemplate', id, 'delete');
await knex('report_templates').where('id', id).del();
}
async function getUserFieldsById(id) {
async function getUserFieldsById(context, id) {
await shares.enforceEntityPermission(context, 'reportTemplate', id, 'view');
const entity = await knex('report_templates').select(['user_fields']).where('id', id).first();
if (!entity) {
throw new interoperableErrors.NotFoundError();

View file

@ -18,7 +18,13 @@ function hash(entity) {
return hasher.hash(filterObject(entity, allowedKeys));
}
async function getByIdWithTemplate(id) {
async function getByIdWithTemplate(context, id) {
await shares.enforceEntityPermission(context, 'report', id, 'view');
return await getByIdWithTemplateNoPerms(id);
}
async function getByIdWithTemplateNoPerms(id) {
const entity = await knex('reports')
.where('reports.id', id)
.innerJoin('report_templates', 'reports.report_template', 'report_templates.id')
@ -35,17 +41,28 @@ async function getByIdWithTemplate(id) {
return entity;
}
async function listDTAjax(params) {
return await dtHelpers.ajaxList(
async function listDTAjax(context, params) {
return await dtHelpers.ajaxListWithPermissions(
context,
[
{ entityTypeId: 'report', requiredOperations: ['view'] },
{ entityTypeId: 'reportTemplate', requiredOperations: ['view'] }
],
params,
tx => tx('reports')
builder => builder.from('reports')
.innerJoin('report_templates', 'reports.report_template', 'report_templates.id')
.innerJoin('namespaces', 'namespaces.id', 'reports.namespace'),
['reports.id', 'reports.name', 'report_templates.name', 'reports.description', 'reports.last_run', 'namespaces.name', 'reports.state', 'report_templates.mime_type']
[
'reports.id', 'reports.name', 'report_templates.name', 'reports.description',
'reports.last_run', 'namespaces.name', 'reports.state', 'report_templates.mime_type'
]
);
}
async function create(entity) {
async function create(context, entity) {
await shares.enforceEntityPermission(context, 'namespace', entity.namespace, 'createReport');
await shares.enforceEntityPermission(context, 'reportTemplate', entity.report_template, 'execute');
let id;
await knex.transaction(async tx => {
await namespaceHelpers.validateEntity(tx, entity);
@ -66,10 +83,13 @@ async function create(entity) {
return id;
}
async function updateWithConsistencyCheck(entity) {
async function updateWithConsistencyCheck(context, entity) {
await shares.enforceEntityPermission(context, 'report', entity.id, 'edit');
await shares.enforceEntityPermission(context, 'reportTemplate', entity.report_template, 'execute');
await knex.transaction(async tx => {
const existing = await tx('reports').where('id', entity.id).first();
if (!entity) {
if (!existing) {
throw new interoperableErrors.NotFoundError();
}
@ -86,6 +106,11 @@ async function updateWithConsistencyCheck(entity) {
await namespaceHelpers.validateEntity(tx, entity);
if (existing.namespace !== entity.namespace) {
await shares.enforceEntityPermission(context, 'namespace', entity.namespace, 'createReport');
await shares.enforceEntityPermission(context, 'report', entity.id, 'delete');
}
entity.params = JSON.stringify(entity.params);
const filteredUpdates = filterObject(entity, allowedKeys);
@ -101,7 +126,9 @@ async function updateWithConsistencyCheck(entity) {
await reportProcessor.start(entity.id);
}
async function remove(id) {
async function remove(context, id) {
await shares.enforceEntityPermission(context, 'report', id, 'delete');
await knex('reports').where('id', id).del();
}
@ -132,7 +159,7 @@ function customFieldName(id) {
return id.replace(/MERGE_/, 'CUSTOM_').toLowerCase();
}
async function getCampaignResults(campaign, select, extra) {
async function getCampaignResults(context, campaign, select, extra) {
const fieldList = await fields.list(campaign.list);
const fieldsMapping = fieldList.reduce((map, field) => {
@ -174,6 +201,7 @@ module.exports = {
ReportState,
hash,
getByIdWithTemplate,
getByIdWithTemplateNoPerms,
listDTAjax,
create,
updateWithConsistencyCheck,

View file

@ -1,55 +1,39 @@
'use strict';
let _ = require('../lib/translate')._;
const knex = require('../lib/knex');
const config = require('config');
const { enforce } = require('../lib/helpers');
const dtHelpers = require('../lib/dt-helpers');
const permissions = require('../lib/permissions');
const interoperableErrors = require('../shared/interoperable-errors');
const entityTypes = {
namespace: {
entitiesTable: 'namespaces',
sharesTable: 'shares_namespace',
permissionsTable: 'permissions_namespace'
},
report: {
entitiesTable: 'reports',
sharesTable: 'shares_report',
permissionsTable: 'permissions_report'
},
reportTemplate: {
entitiesTable: 'report_templates',
sharesTable: 'shares_report_template',
permissionsTable: 'permissions_report_template'
}
};
function getEntityType(entityTypeId) {
const entityType = entityTypes[entityTypeId];
async function listDTAjax(context, entityTypeId, entityId, params) {
const entityType = permissions.getEntityType(entityTypeId);
if (!entityType) {
throw new Error(`Unknown entity type ${entityTypeId}`);
}
await enforceEntityPermission(context, entityTypeId, entityId, 'share');
return entityType
return await dtHelpers.ajaxList(params, builder => builder.from(entityType.sharesTable).innerJoin('users', entityType.sharesTable + '.user', 'users.id').where(`${entityType.sharesTable}.entity`, entityId), [entityType.sharesTable + '.id', 'users.username', 'users.name', entityType.sharesTable + '.role', 'users.id']);
}
async function listDTAjax(entityTypeId, entityId, params) {
const entityType = getEntityType(entityTypeId);
return await dtHelpers.ajaxList(params, tx => tx(entityType.sharesTable).innerJoin('users', entityType.sharesTable + '.user', 'users.id').where(`${entityType.sharesTable}.entity`, entityId), [entityType.sharesTable + '.id', 'users.username', 'users.name', entityType.sharesTable + '.role', 'users.id']);
}
async function listUnassignedUsersDTAjax(context, entityTypeId, entityId, params) {
const entityType = permissions.getEntityType(entityTypeId);
await enforceEntityPermission(context, entityTypeId, entityId, 'share');
async function listUnassignedUsersDTAjax(entityTypeId, entityId, params) {
const entityType = getEntityType(entityTypeId);
return await dtHelpers.ajaxList(
params,
tx => tx('users').whereNotExists(function() { return this.select('*').from(entityType.sharesTable).whereRaw(`users.id = ${entityType.sharesTable}.user`).andWhere(`${entityType.sharesTable}.entity`, entityId); }),
builder => builder.from('users').whereNotExists(function() { return this.select('*').from(entityType.sharesTable).whereRaw(`users.id = ${entityType.sharesTable}.user`).andWhere(`${entityType.sharesTable}.entity`, entityId); }),
['users.id', 'users.username', 'users.name']);
}
async function assign(entityTypeId, entityId, userId, role) {
const entityType = getEntityType(entityTypeId);
async function assign(context, entityTypeId, entityId, userId, role) {
const entityType = permissions.getEntityType(entityTypeId);
await enforceEntityPermission(context, entityTypeId, entityId, 'share');
await knex.transaction(async tx => {
enforce(await tx('users').where('id', userId).select('id').first(), 'Invalid user id');
enforce(await tx(entityType.entitiesTable).where('id', entityId).select('id').first(), 'Invalid entity id');
@ -79,26 +63,92 @@ async function assign(entityTypeId, entityId, userId, role) {
});
}
async function rebuildPermissions(tx, restriction) {
restriction = restriction || {};
async function _rebuildPermissions(tx, restriction) {
const namespaceEntityType = permissions.getEntityType('namespace');
// Collect entity types we care about
let restrictedEntityTypes;
if (restriction.entityTypeId) {
const entityType = permissions.getEntityType(restriction.entityTypeId);
restrictedEntityTypes = {
[restriction.entityTypeId]: entityTypes[restriction.entityTypeId]
[restriction.entityTypeId]: entityType
};
} else {
restrictedEntityTypes = entityTypes;
restrictedEntityTypes = permissions.getEntityTypes();
}
const namespaces = await tx('namespaces').select(['id', 'namespace']);
// 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;
}
}
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
const usersWithRoleInOwnNamespaceQuery = tx('users')
.leftJoin(namespaceEntityType.sharesTable, {
'users.id': `${namespaceEntityType.sharesTable}.user`,
'users.namespace': `${namespaceEntityType.sharesTable}.entity`
})
.select(['users.id', 'users.namespace', 'users.role as userRole', `${namespaceEntityType.sharesTable}.role`]);
if (restriction.userId) {
usersWithRoleInOwnNamespaceQuery.where('user', restriction.userId);
}
const usersWithRoleInOwnNamespace = await usersWithRoleInOwnNamespaceQuery;
for (const user of usersWithRoleInOwnNamespace) {
const roleConf = config.roles.global[user.userRole];
if (roleConf) {
const desiredRole = roleConf.ownNamespaceRole;
if (desiredRole && user.role !== desiredRole) {
await tx(namespaceEntityType.sharesTable).where({ user: user.id, entity: user.namespace }).del();
await tx(namespaceEntityType.sharesTable).insert({ user: user.id, entity: user.namespace, role: desiredRole });
}
}
}
const usersWithRoleInRootNamespaceQuery = tx('users')
.leftJoin(namespaceEntityType.sharesTable, 'users.id', `${namespaceEntityType.sharesTable}.user`)
.where(`${namespaceEntityType.sharesTable}.entity`, 1 /* Global namespace id */)
.select(['users.id', 'users.role as userRole', `${namespaceEntityType.sharesTable}.role`]);
if (restriction.userId) {
usersWithRoleInRootNamespaceQuery.andWhere('user', restriction.userId);
}
const usersWithRoleInRootNamespace = await usersWithRoleInRootNamespaceQuery;
for (const user of usersWithRoleInRootNamespace) {
const roleConf = config.roles.global[user.userRole];
if (roleConf) {
const desiredRole = roleConf.rootNamespaceRole;
if (desiredRole && user.role !== desiredRole) {
await tx(namespaceEntityType.sharesTable).where({ user: user.id, entity: 1 /* Global namespace id */ }).del();
await tx(namespaceEntityType.sharesTable).insert({ user: user.id, entity: 1 /* Global namespace id */, role: desiredRole });
}
}
}
// Build the map of all namespaces
// nsMap is a map of namespaces - each of the following shape:
// .id - id of the namespace
// .namespace - id of the parent or null if no parent
// .userPermissions - Map userId -> [entityTypeId] -> array of permissions
// .transitiveUserPermissions - the same as above, but taking into account transitive permission obtained from namespace parents
const namespaces = await tx('namespaces').select(['id', 'namespace']);
const nsMap = new Map();
for (const namespace of namespaces) {
namespace.userPermissions = new Map();
@ -106,9 +156,9 @@ async function rebuildPermissions(tx, restriction) {
}
// This populates .userPermissions
const nsSharesQuery = tx(entityTypes.namespace.sharesTable).select(['entity', 'user', 'role']);
const nsSharesQuery = tx(namespaceEntityType.sharesTable).select(['entity', 'user', 'role']);
if (restriction.userId) {
nsSharesQuery.andWhere('user', restriction.userId);
nsSharesQuery.where('user', restriction.userId);
}
const nsShares = await nsSharesQuery;
@ -120,10 +170,10 @@ async function rebuildPermissions(tx, restriction) {
for (const entityTypeId in restrictedEntityTypes) {
if (config.roles.namespace[nsShare.role] &&
config.roles.namespace[nsShare.role].childperms &&
config.roles.namespace[nsShare.role].childperms[entityTypeId]) {
config.roles.namespace[nsShare.role].children &&
config.roles.namespace[nsShare.role].children[entityTypeId]) {
userPerms[entityTypeId] = new Set(config.roles.namespace[nsShare.role].childperms[entityTypeId]);
userPerms[entityTypeId] = new Set(config.roles.namespace[nsShare.role].children[entityTypeId]);
} else {
userPerms[entityTypeId] = new Set();
@ -174,22 +224,22 @@ async function rebuildPermissions(tx, restriction) {
}
// This reads direct shares from DB, joins it with the permissions from namespaces and stores the permissions into DB
// This reads direct shares from DB, joins each with the permissions from namespaces and stores the permissions into DB
for (const entityTypeId in restrictedEntityTypes) {
const entityType = restrictedEntityTypes[entityTypeId];
const expungeQuery = tx(entityType.permissionsTable).del();
if (restriction.entityId) {
expungeQuery.andWhere('entity', restriction.entityId);
expungeQuery.where('entity', restriction.entityId);
}
if (restriction.userId) {
expungeQuery.andWhere('user', restriction.userId);
expungeQuery.where('user', restriction.userId);
}
await expungeQuery;
const entitiesQuery = tx(entityType.entitiesTable).select(['id', 'namespace']);
if (restriction.entityId) {
entitiesQuery.andWhere('id', restriction.entityId);
entitiesQuery.where('id', restriction.entityId);
}
const entities = await entitiesQuery;
@ -199,7 +249,7 @@ async function rebuildPermissions(tx, restriction) {
if (entity.namespace) { // The root namespace has not parent namespace, thus the test
const transitiveUserPermissions = nsMap.get(entity.namespace).transitiveUserPermissions;
for (const transitivePermsPair of transitiveUserPermissions.entries()) {
permsPerUser.set(transitivePermsPair[0], [...transitivePermsPair[1][entityTypeId]]);
permsPerUser.set(transitivePermsPair[0], new Set(transitivePermsPair[1][entityTypeId]));
}
}
@ -242,9 +292,96 @@ async function rebuildPermissions(tx, restriction) {
}
}
async function rebuildPermissions(tx, restriction) {
restriction = restriction || {};
if (tx) {
await _rebuildPermissions(tx, restriction);
} else {
await knex.transaction(async tx => {
await _rebuildPermissions(tx, restriction);
});
}
}
function throwPermissionDenied() {
throw new interoperableErrors.PermissionDeniedError(_('Permission denied'));
}
function enforceGlobalPermission(context, requiredOperations) {
if (typeof requiredOperations === 'string') {
requiredOperations = [ requiredOperations ];
}
const roleSpec = config.roles.global[context.user.role];
if (roleSpec) {
for (const requiredOperation of requiredOperations) {
if (roleSpec.permissions.includes(requiredOperation)) {
return;
}
}
}
throwPermissionDenied();
}
async function _checkPermission(context, entityTypeId, entityId, requiredOperations) {
const entityType = permissions.getEntityType(entityTypeId);
if (typeof requiredOperations === 'string') {
requiredOperations = [ requiredOperations ];
}
const permsQuery = await knex(entityType.permissionsTable)
.where('user', context.user.id)
.whereIn('operation', requiredOperations);
if (entityId) {
permsQuery.andWhere('entity', entityId);
}
const perms = await permsQuery.first();
return !!perms;
}
async function checkEntityPermission(context, entityTypeId, entityId, requiredOperations) {
if (!entityId) {
return false;
}
return await _checkEntityPermission(context, entityTypeId, entityId, requiredOperations);
}
async function checkTypePermission(context, entityTypeId, requiredOperations) {
return await _checkEntityPermission(context, entityTypeId, null, requiredOperations);
}
async function enforceEntityPermission(context, entityTypeId, entityId, requiredOperations) {
const perms = await checkEntityPermission(context, entityTypeId, entityId, requiredOperations);
if (!perms) {
throwPermissionDenied();
}
}
async function enforceTypePermission(context, entityTypeId, requiredOperations) {
const perms = await checkTypePermission(context, entityTypeId, requiredOperations);
if (!perms) {
throwPermissionDenied();
}
}
module.exports = {
listDTAjax,
listUnassignedUsersDTAjax,
assign,
rebuildPermissions
rebuildPermissions,
enforceEntityPermission,
enforceTypePermission,
checkEntityPermission,
checkTypePermission,
enforceGlobalPermission,
throwPermissionDenied
};

View file

@ -1,11 +1,11 @@
'use strict';
const config = require('config');
const knex = require('../lib/knex');
const hasher = require('node-object-hash')();
const { enforce, filterObject } = require('../lib/helpers');
const interoperableErrors = require('../shared/interoperable-errors');
const passwordValidator = require('../shared/password-validator')();
const validators = require('../shared/validators');
const dtHelpers = require('../lib/dt-helpers');
const tools = require('../lib/tools-async');
let crypto = require('crypto');
@ -26,20 +26,19 @@ const passport = require('../lib/passport');
const namespaceHelpers = require('../lib/namespace-helpers');
const allowedKeys = new Set(['username', 'name', 'email', 'password', 'namespace']);
const allowedKeysExternal = new Set(['username']);
const allowedKeys = new Set(['username', 'name', 'email', 'password', 'namespace', 'role']);
const ownAccountAllowedKeys = new Set(['name', 'email', 'password']);
const hashKeys = new Set(['username', 'name', 'email', 'namespace']);
const defaultNamespace = 1;
const allowedKeysExternal = new Set(['username', 'namespace', 'role']);
const hashKeys = new Set(['username', 'name', 'email', 'namespace', 'role']);
const shares = require('../../models/shares');
function hash(entity) {
return hasher.hash(filterObject(entity, hashKeys));
}
async function _getBy(key, value, extraColumns) {
const columns = ['id', 'username', 'name', 'email', 'namespace'];
async function _getBy(context, key, value, extraColumns) {
const columns = ['id', 'username', 'name', 'email', 'namespace', 'role'];
if (extraColumns) {
columns.push(...extraColumns);
@ -48,19 +47,35 @@ async function _getBy(key, value, extraColumns) {
const user = await knex('users').select(columns).where(key, value).first();
if (!user) {
throw new interoperableErrors.NotFoundError();
if (context) {
shares.throwPermissionDenied();
} else {
throw new interoperableErrors.NotFoundError();
}
}
if (context) {
await shares.enforceEntityPermission(context, 'namespace', user.namespace, 'manageUsers');
}
return user;
}
async function getById(id) {
return await _getBy('id', id);
async function getById(context, id) {
return await _getBy(context, 'id', id);
}
async function serverValidate(data, isOwnAccount) {
async function getByIdNoPerms(id) {
return await _getBy(null, 'id', id);
}
async function serverValidate(context, data, isOwnAccount) {
const result = {};
if (!isOwnAccount) {
await shares.enforceTypePermission(context, 'namespace', 'manageUsers');
}
if (!isOwnAccount && data.username) {
const query = knex('users').select(['id']).where('username', data.username);
@ -100,11 +115,17 @@ async function serverValidate(data, isOwnAccount) {
return result;
}
async function listDTAjax(params) {
return await dtHelpers.ajaxList(
async function listDTAjax(context, params) {
return await dtHelpers.ajaxListWithPermissions(
context,
[{ entityTypeId: 'namespace', requiredOperations: ['manageUsers'] }],
params,
tx => tx('users').innerJoin('namespaces', 'namespaces.id', 'users.namespace'),
['users.id', 'users.username', 'users.name', 'namespaces.name']
builder => builder.from('users').innerJoin('namespaces', 'namespaces.id', 'users.namespace'),
['users.id', 'users.username', 'users.name', 'namespaces.name', 'users.role'],
data => {
const role = data[4];
data[4] = config.roles.global[role] ? config.roles.global[role].name : role;
}
);
}
@ -124,8 +145,6 @@ async function _validateAndPreprocess(tx, user, isCreate, isOwnAccount) {
if (!isOwnAccount) {
enforce(validators.usernameValid(user.username), 'Invalid username');
const otherUserWithSameUsernameQuery = tx('users').where('username', user.username);
if (user.id) {
otherUserWithSameUsernameQuery.andWhereNot('id', user.id);
@ -136,6 +155,7 @@ async function _validateAndPreprocess(tx, user, isCreate, isOwnAccount) {
}
}
enforce(user.role in config.roles.global, 'Unknown role');
enforce(!isCreate || user.password.length > 0, 'Password not set');
@ -153,70 +173,89 @@ async function _validateAndPreprocess(tx, user, isCreate, isOwnAccount) {
}
async function create(user) {
enforce(passport.isAuthMethodLocal, 'Local user management is required');
await shares.enforceEntityPermission(context, 'namespace', user.namespace, 'manageUsers');
await knex.transaction(async tx => {
await _validateAndPreprocess(tx, user, true);
const userId = await tx('users').insert(filterObject(user, allowedKeys));
return userId;
if (passport.isAuthMethodLocal) {
await _validateAndPreprocess(tx, user, true);
const userId = await tx('users').insert(filterObject(user, allowedKeys));
return userId;
} else {
const filteredUser = filterObject(user, allowedKeysExternal);
enforce(user.role in config.roles.global, 'Unknown role');
await namespaceHelpers.validateEntity(tx, user);
const userId = await knex('users').insert(filteredUser);
return userId;
}
});
}
async function createExternal(user) {
enforce(!passport.isAuthMethodLocal, 'External user management is required');
const filteredUser = filterObject(user, allowedKeysExternal);
filteredUser.namespace = defaultNamespace;
const userId = await knex('users').insert(filteredUser);
return userId;
}
async function updateWithConsistencyCheck(user, isOwnAccount) {
enforce(passport.isAuthMethodLocal, 'Local user management is required');
await knex.transaction(async tx => {
await _validateAndPreprocess(tx, user, false, isOwnAccount);
const existingUser = await tx('users').where('id', user.id).first();
if (!user) {
throw new interoperableErrors.NotFoundError();
const existing = await tx('users').where('id', user.id).first();
if (!existing) {
shares.throwPermissionDenied();
}
const existingUserHash = hash(existingUser);
if (existingUserHash !== user.originalHash) {
const existingHash = hash(existing);
if (existingHash !== user.originalHash) {
throw new interoperableErrors.ChangedError();
}
if (isOwnAccount && user.password) {
if (!await bcryptCompare(user.currentPassword, existingUser.password)) {
throw new interoperableErrors.IncorrectPasswordError();
}
if (!isOwnAccount) {
await shares.enforceEntityPermission(context, 'namespace', user.namespace, 'manageUsers');
await shares.enforceEntityPermission(context, 'namespace', existing.namespace, 'manageUsers');
}
await tx('users').where('id', user.id).update(filterObject(user, isOwnAccount ? ownAccountAllowedKeys : allowedKeys));
if (passport.isAuthMethodLocal) {
await _validateAndPreprocess(tx, user, false, isOwnAccount);
if (isOwnAccount && user.password) {
if (!await bcryptCompare(user.currentPassword, existing.password)) {
throw new interoperableErrors.IncorrectPasswordError();
}
}
await tx('users').where('id', user.id).update(filterObject(user, isOwnAccount ? ownAccountAllowedKeys : allowedKeys));
} else {
enforce(isOwnAccount, 'Local user management is required');
enforce(user.role in config.roles.global, 'Unknown role');
await namespaceHelpers.validateEntity(tx, user);
await tx('users').where('id', user.id).update(filterObject(user, allowedKeysExternal));
}
});
}
async function remove(context, userId) {
enforce(passport.isAuthMethodLocal, 'Local user management is required');
enforce(userId !== 1, 'Admin cannot be deleted');
enforce(context.user.id !== userId, 'User cannot delete himself/herself');
await knex('users').where('id', userId).del();
await knex.transaction(async tx => {
const existing = await tx('users').where('id', userId).first();
if (!existing) {
shares.throwPermissionDenied();
}
await shares.enforceEntityPermission(context, 'namespace', existing.namespace, 'manageUsers');
await knex('users').where('id', userId).del();
});
}
async function getByAccessToken(accessToken) {
return await _getBy('access_token', accessToken);
return await _getBy(null, 'access_token', accessToken);
}
async function getByUsername(username) {
return await _getBy('username', username);
return await _getBy(null, 'username', username);
}
async function getByUsernameIfPasswordMatch(username, password) {
try {
const user = await _getBy('username', username, ['password']);
const user = await _getBy(null, 'username', username, ['password']);
if (!await bcryptCompare(password, user.password)) {
throw new interoperableErrors.IncorrectPasswordError();
@ -234,19 +273,13 @@ async function getByUsernameIfPasswordMatch(username, password) {
}
async function getAccessToken(userId) {
const user = await _getBy('id', userId, ['access_token']);
const user = await _getBy(null, 'id', userId, ['access_token']);
return user.access_token;
}
async function resetAccessToken(userId) {
const token = crypto.randomBytes(20).toString('hex').toLowerCase();
const affectedRows = await knex('users').where({id: userId}).update({access_token: token});
if (!affectedRows) {
throw new interoperableErrors.NotFoundError();
}
await knex('users').where({id: userId}).update({access_token: token});
return token;
}
@ -331,9 +364,9 @@ module.exports = {
remove,
updateWithConsistencyCheck,
create,
createExternal,
hash,
getById,
getByIdNoPerms,
serverValidate,
getByAccessToken,
getByUsername,

View file

@ -4,11 +4,14 @@ const passport = require('../lib/passport');
const _ = require('../lib/translate')._;
const reports = require('../models/reports');
const fileHelpers = require('../lib/file-helpers');
const shares = require('../models/shares');
const router = require('../lib/router-async').create();
router.getAsync('/download/:id', passport.loggedIn, async (req, res) => {
const report = await reports.getByIdWithTemplate(req.params.id);
await shares.enforceEntityPermission(req.context, 'report', req.params.id, 'viewContent');
const report = await reports.getByIdWithTemplateNoPerms(req.params.id);
if (report.state == reports.ReportState.FINISHED) {
const headers = {

View file

@ -8,7 +8,7 @@ const router = require('../../lib/router-async').create();
router.getAsync('/account', passport.loggedIn, async (req, res) => {
const user = await users.getById(req.user.id);
const user = await users.getByIdNoPerms(req.user.id);
user.hash = users.hash(user);
return res.json(user);
});

View file

@ -8,7 +8,7 @@ const router = require('../../lib/router-async').create();
router.getAsync('/namespaces/:nsId', passport.loggedIn, async (req, res) => {
const ns = await namespaces.getById(req.params.nsId);
const ns = await namespaces.getById(req.context, req.params.nsId);
ns.hash = namespaces.hash(ns);
@ -16,7 +16,7 @@ router.getAsync('/namespaces/:nsId', passport.loggedIn, async (req, res) => {
});
router.postAsync('/namespaces', passport.loggedIn, passport.csrfProtection, async (req, res) => {
await namespaces.create(req.body);
await namespaces.create(req.context, req.body);
return res.json();
});
@ -24,16 +24,19 @@ router.putAsync('/namespaces/:nsId', passport.loggedIn, passport.csrfProtection,
const ns = req.body;
ns.id = parseInt(req.params.nsId);
await namespaces.updateWithConsistencyCheck(ns);
await namespaces.updateWithConsistencyCheck(req.context, ns);
return res.json();
});
router.deleteAsync('/namespaces/:nsId', passport.loggedIn, passport.csrfProtection, async (req, res) => {
await namespaces.remove(req.params.nsId);
await namespaces.remove(req.context, req.params.nsId);
return res.json();
});
router.getAsync('/namespaces-tree', passport.loggedIn, async (req, res) => {
// FIXME - process permissions
const entries = {};
let root; // Only the Root namespace is without a parent
const rows = await namespaces.list();

View file

@ -8,13 +8,13 @@ const router = require('../../lib/router-async').create();
router.getAsync('/report-templates/:reportTemplateId', passport.loggedIn, async (req, res) => {
const reportTemplate = await reportTemplates.getById(req.params.reportTemplateId);
const reportTemplate = await reportTemplates.getById(req.context, req.params.reportTemplateId);
reportTemplate.hash = reportTemplates.hash(reportTemplate);
return res.json(reportTemplate);
});
router.postAsync('/report-templates', passport.loggedIn, passport.csrfProtection, async (req, res) => {
await reportTemplates.create(req.body);
await reportTemplates.create(req.context, req.body);
return res.json();
});
@ -22,23 +22,27 @@ router.putAsync('/report-templates/:reportTemplateId', passport.loggedIn, passpo
const reportTemplate = req.body;
reportTemplate.id = parseInt(req.params.reportTemplateId);
await reportTemplates.updateWithConsistencyCheck(reportTemplate);
await reportTemplates.updateWithConsistencyCheck(req.context, reportTemplate);
return res.json();
});
router.deleteAsync('/report-templates/:reportTemplateId', passport.loggedIn, passport.csrfProtection, async (req, res) => {
await reportTemplates.remove(req.params.reportTemplateId);
await reportTemplates.remove(req.context, req.params.reportTemplateId);
return res.json();
});
router.postAsync('/report-templates-table', passport.loggedIn, async (req, res) => {
return res.json(await reportTemplates.listDTAjax(req.body));
return res.json(await reportTemplates.listDTAjax(req.context, req.body));
});
router.getAsync('/report-template-user-fields/:reportTemplateId', passport.loggedIn, async (req, res) => {
const userFields = await reportTemplates.getUserFieldsById(req.params.reportTemplateId);
const userFields = await reportTemplates.getUserFieldsById(req.context, req.params.reportTemplateId);
return res.json(userFields);
});
router.getAsync('/report-templates-create-permitted', passport.loggedIn, async (req, res) => {
return res.json(await shares.checkTypePermission(req.context, 'namespace', 'createReportTemplate'));
});
module.exports = router;

View file

@ -5,18 +5,19 @@ const _ = require('../../lib/translate')._;
const reports = require('../../models/reports');
const reportProcessor = require('../../lib/report-processor');
const fileHelpers = require('../../lib/file-helpers');
const shares = require('../../models/shares');
const router = require('../../lib/router-async').create();
router.getAsync('/reports/:reportId', passport.loggedIn, async (req, res) => {
const report = await reports.getByIdWithTemplate(req.params.reportId);
const report = await reports.getByIdWithTemplate(req.context, req.params.reportId);
report.hash = reports.hash(report);
return res.json(report);
});
router.postAsync('/reports', passport.loggedIn, passport.csrfProtection, async (req, res) => {
await reports.create(req.body);
await reports.create(req.context, req.body);
return res.json();
});
@ -24,36 +25,50 @@ router.putAsync('/reports/:reportId', passport.loggedIn, passport.csrfProtection
const report = req.body;
report.id = parseInt(req.params.reportId);
await reports.updateWithConsistencyCheck(report);
await reports.updateWithConsistencyCheck(req.context, report);
return res.json();
});
router.deleteAsync('/reports/:reportId', passport.loggedIn, passport.csrfProtection, async (req, res) => {
await reports.remove(req.params.reportId);
await reports.remove(req.context, req.params.reportId);
return res.json();
});
router.postAsync('/reports-table', passport.loggedIn, async (req, res) => {
return res.json(await reports.listDTAjax(req.body));
return res.json(await reports.listDTAjax(req.context, req.body));
});
router.postAsync('/report-start/:id', passport.loggedIn, passport.csrfProtection, async (req, res) => {
await shares.enforceEntityPermission(req.context, 'report', req.params.id, 'execute');
const report = await reports.getByIdWithTemplateNoPerms(req.params.id);
await shares.enforceEntityPermission(req.context, 'reportTemplate', report.report_template, 'execute');
await reportProcessor.start(req.params.id);
res.json();
});
router.postAsync('/report-stop/:id', async (req, res) => {
await shares.enforceEntityPermission(req.context, 'report', req.params.id, 'execute');
const report = await reports.getByIdWithTemplateNoPerms(req.params.id);
await shares.enforceEntityPermission(req.context, 'reportTemplate', report.report_template, 'execute');
await reportProcessor.stop(req.params.id);
res.json();
});
router.getAsync('/report-content/:id', async (req, res) => {
const report = await reports.getByIdWithTemplate(req.params.id);
await shares.enforceEntityPermission(req.context, 'report', req.params.id, 'viewContent');
const report = await reports.getByIdWithTemplateNoPerms(req.params.id);
res.sendFile(fileHelpers.getReportContentFile(report));
});
router.getAsync('/report-output/:id', async (req, res) => {
const report = await reports.getByIdWithTemplate(req.params.id);
await shares.enforceEntityPermission(req.context, 'report', req.params.id, 'viewOutput');
const report = await reports.getByIdWithTemplateNoPerms(req.params.id);
res.sendFile(fileHelpers.getReportOutputFile(report));
});

View file

@ -8,20 +8,63 @@ const permissions = require('../../lib/permissions')
const router = require('../../lib/router-async').create();
router.postAsync('/shares-table/:entityTypeId/:entityId', passport.loggedIn, async (req, res) => {
return res.json(await shares.listDTAjax(req.params.entityTypeId, req.params.entityId, req.body));
return res.json(await shares.listDTAjax(req.context, req.params.entityTypeId, req.params.entityId, req.body));
});
router.postAsync('/shares-users-table/:entityTypeId/:entityId', passport.loggedIn, async (req, res) => {
return res.json(await shares.listUnassignedUsersDTAjax(req.params.entityTypeId, req.params.entityId, req.body));
return res.json(await shares.listUnassignedUsersDTAjax(req.context, req.params.entityTypeId, req.params.entityId, req.body));
});
router.putAsync('/shares', passport.loggedIn, async (req, res) => {
// FIXME: Check that the user has the right to assign the role
const body = req.body;
await shares.assign(body.entityTypeId, body.entityId, body.userId, body.role);
await shares.assign(req.context, body.entityTypeId, body.entityId, body.userId, body.role);
return res.json();
});
/*
Checks if entities with a given permission exist.
Accepts format:
{
XXX1: {
entityTypeId: ...
requiredOperations: [ ... ]
},
XXX2: {
entityTypeId: ...
requiredOperations: [ ... ]
}
}
Returns:
{
XXX1: true
XXX2: false
}
*/
router.postAsync('/permissions-check', passport.loggedIn, async (req, res) => {
const body = req.body;
const result = {};
for (const reqKey in body) {
if (body[reqKey].entityId) {
result[reqKey] = await shares.checkEntityPermission(req.context, body[reqKey].entityTypeId, body[reqKey].entityId, body[reqKey].requiredOperations);
} else {
result[reqKey] = await shares.checkTypePermission(req.context, body[reqKey].entityTypeId, body[reqKey].requiredOperations);
}
}
return res.json(result);
});
router.postAsync('/permissions-rebuild', passport.loggedIn, async (req, res) => {
shares.enforceGlobalPermission(req.context, 'rebuildPermissions');
shares.rebuildPermissions();
return res.json(result);
});
module.exports = router;

View file

@ -1,14 +1,16 @@
'use strict';
const config = require('config');
const passport = require('../../lib/passport');
const _ = require('../../lib/translate')._;
const users = require('../../models/users');
const shares = require('../../models/shares');
const router = require('../../lib/router-async').create();
router.getAsync('/users/:userId', passport.loggedIn, async (req, res) => {
const user = await users.getById(req.params.userId);
const user = await users.getById(req.context, req.params.userId);
user.hash = users.hash(user);
return res.json(user);
});
@ -32,11 +34,12 @@ router.deleteAsync('/users/:userId', passport.loggedIn, passport.csrfProtection,
});
router.postAsync('/users-validate', passport.loggedIn, async (req, res) => {
return res.json(await users.serverValidate(req.body));
return res.json(await users.serverValidate(req.context, req.body));
});
router.postAsync('/users-table', passport.loggedIn, async (req, res) => {
return res.json(await users.listDTAjax(req.body));
return res.json(await users.listDTAjax(req.context, req.body));
});
module.exports = router;

View file

@ -9,7 +9,7 @@ exports.up = function(knex, Promise) {
table.integer('namespace').unsigned().references('namespaces.id').onDelete('CASCADE');
})
.then(() => knex('namespaces').insert({
id: 1,
id: 1, /* Global namespace id */
name: 'Global',
description: 'Global namespace'
}));
@ -20,7 +20,7 @@ exports.up = function(knex, Promise) {
table.integer('namespace').unsigned().notNullable();
}))
.then(() => knex(`${entityType}s`).update({
namespace: 1
namespace: 1 /* Global namespace id */
}))
.then(() => knex.schema.table(`${entityType}s`, table => {
table.foreign('namespace').references('namespaces.id').onDelete('CASCADE');

View file

@ -21,12 +21,7 @@ exports.up = function(knex, Promise) {
});
}
schema = schema.then(() => knex('shares_namespace').insert({
id: 1,
entity: 1,
user: 1,
role: 'master'
}));
/* The global share for admin is set automatically in rebuild permissions, which is called upon every start */
return schema;
};

View file

@ -4,7 +4,7 @@ exports.up = function(knex, Promise) {
table.string('name');
table.string('password').alter();
})
.then(() => knex('users').where('id', 1).update({
.then(() => knex('users').where('id', 1 /* Admin user id */).update({
name: 'Administrator'
}));
};

View file

@ -0,0 +1,9 @@
exports.up = function(knex, Promise) {
return knex.schema.table('users', table => {
table.string('role');
});
/* The user role is set automatically in rebuild permissions, which is called upon every start */
};
exports.down = function(knex, Promise) {
};

View file

@ -68,6 +68,12 @@ class DependencyNotFoundError extends InteroperableError {
}
}
class PermissionDeniedError extends InteroperableError {
constructor(msg, data) {
super('PermissionDeniedError', msg, data);
}
}
const errorTypes = {
InteroperableError,
NotLoggedInError,
@ -79,7 +85,8 @@ const errorTypes = {
DuplicitEmailError,
IncorrectPasswordError,
InvalidTokenError,
DependencyNotFoundError
DependencyNotFoundError,
PermissionDeniedError
};
function deserialize(errorObj) {

View file

@ -1,9 +0,0 @@
'use strict';
function usernameValid(username) {
return /^[a-zA-Z0-9][a-zA-Z0-9_\-.]*$/.test(username);
}
module.exports = {
usernameValid
};

View file

@ -13,6 +13,7 @@ const vm = require('vm');
const log = require('npmlog');
const fs = require('fs');
const knex = require('../../lib/knex');
const contextHelpers = require('../../lib/context-helpers');
handlebarsHelpers.registerHelpers(handlebars);
@ -25,9 +26,11 @@ const userFieldGetters = {
async function main() {
try {
const context = contextHelpers.getServiceContext();
const reportId = Number(process.argv[2]);
const report = await reports.getByIdWithTemplate(reportId);
const report = await reports.getByIdWithTemplate(context, reportId);
const inputs = {};
@ -50,7 +53,7 @@ async function main() {
}
const campaignsProxy = {
getResults: reports.getCampaignResults,
getResults: (campaign, select, extra) => reports.getCampaignResults(context, campaign, select, extra),
getById: campaigns.getById
};