diff --git a/base_export_security/README.rst b/base_export_security/README.rst new file mode 100644 index 000000000..f972c0071 --- /dev/null +++ b/base_export_security/README.rst @@ -0,0 +1,92 @@ +.. image:: https://img.shields.io/badge/licence-LGPL--3-blue.svg + :target: http://www.gnu.org/licenses/lgpl + :alt: License: LGPL-3 + +=============== +Export Security +=============== + +This module adds the following security features to Odoo's data exporting: + +#. A security group for restricting users' ability to export data +#. A log of exports detailing the user responsible, as well as the model, records, and fields exported +#. A `#data exports` channel for export activity notifications + +Screenshots +=========== + +Hidden Export Menu Item +----------------------- + +.. image:: /base_export_security/static/description/export_menu_item_hidden.png?raw=true + :alt: Hidden Export Menu Item + :width: 600 px + +Export Log +---------- + +.. image:: /base_export_security/static/description/export_log.png?raw=true + :alt: Export Log + :width: 600 px + +Channel Notification +-------------------- + +.. image:: /base_export_security/static/description/export_notification.png?raw=true + :alt: Export Notification + :width: 500 px + +Configuration +============= + +To configure this module, you need to: + +#. Add users who require data export rights to the `Export Rights` security group, found in Settings/Users (requires developer mode to be active) +#. Invite users who should receive export activity notifications to the `#data exports` notification channel + +Usage +===== + +To view detailed Export Logs, go to Settings/Data Exports/Export Logs + +.. image:: https://odoo-community.org/website/image/ir.attachment/5784_f2813bd/datas + :alt: Try me on Runbot + :target: https://runbot.odoo-community.org/runbot/149/10.0 + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues +`_. In case of trouble, please +check there if your issue has already been reported. If you spotted it first, +help us smash it by providing detailed and welcomed feedback. + +Credits +======= + +Images +------ + +* Odoo Community Association: `Icon `_. + +Contributors +------------ + +* Brent Hughes + +Do not contact contributors directly about support or help with technical issues. + +Maintainer +---------- + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +This module is maintained by the OCA. + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +To contribute to this module, please visit https://odoo-community.org. diff --git a/base_export_security/__init__.py b/base_export_security/__init__.py new file mode 100644 index 000000000..fd02263ce --- /dev/null +++ b/base_export_security/__init__.py @@ -0,0 +1,5 @@ +# -*- coding: utf-8 -*- +# Copyright 2017 LasLabs Inc. +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). + +from . import models diff --git a/base_export_security/__manifest__.py b/base_export_security/__manifest__.py new file mode 100644 index 000000000..84633409a --- /dev/null +++ b/base_export_security/__manifest__.py @@ -0,0 +1,25 @@ +# -*- coding: utf-8 -*- +# Copyright 2017 LasLabs Inc. +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). + +{ + "name": "Export Security", + "summary": "Security features for Odoo exports", + "version": "10.0.1.0.0", + "category": "Extra Tools", + "website": "https://laslabs.com/", + "author": "LasLabs, Odoo Community Association (OCA)", + "license": "LGPL-3", + "application": False, + "installable": True, + "depends": [ + "mail", + "web", + ], + "data": [ + "data/export.xml", + "security/export_security.xml", + "security/ir.model.access.csv", + "views/export_view.xml", + ], +} diff --git a/base_export_security/data/export.xml b/base_export_security/data/export.xml new file mode 100644 index 000000000..2b52422c6 --- /dev/null +++ b/base_export_security/data/export.xml @@ -0,0 +1,12 @@ + + + + + + data exports + private + + Notifications of data export activity + + diff --git a/base_export_security/models/__init__.py b/base_export_security/models/__init__.py new file mode 100644 index 000000000..c55e948b7 --- /dev/null +++ b/base_export_security/models/__init__.py @@ -0,0 +1,6 @@ +# -*- coding: utf-8 -*- +# Copyright 2017 LasLabs Inc. +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). + +from . import base +from . import export diff --git a/base_export_security/models/base.py b/base_export_security/models/base.py new file mode 100644 index 000000000..c3aeff556 --- /dev/null +++ b/base_export_security/models/base.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- +# Copyright 2017 LasLabs Inc. +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). + +from odoo import _, api, models +from odoo.exceptions import AccessError + + +class Base(models.AbstractModel): + _inherit = 'base' + + @api.multi + def export_data(self, fields_to_export, raw_data=False): + if self.env.user.has_group('base_export_security.export_group'): + field_names = map( + lambda path_array: path_array[0], map( + models.fix_import_export_id_paths, + fields_to_export, + ), + ) + self.env['export.event'].log_export(self, field_names) + return super(Base, self).export_data(fields_to_export, raw_data) + else: + raise AccessError( + _('You do not have permission to export data'), + ) diff --git a/base_export_security/models/export.py b/base_export_security/models/export.py new file mode 100644 index 000000000..78527419e --- /dev/null +++ b/base_export_security/models/export.py @@ -0,0 +1,81 @@ +# -*- coding: utf-8 -*- +# Copyright 2017 LasLabs Inc. +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). + +from odoo import _, api, fields, models + + +class Export(models.Model): + _name = 'export.event' + _description = 'Data Export Record' + + name = fields.Char() + model_id = fields.Many2one( + 'ir.model', + string='Exported Model', + readonly=True, + ) + user_id = fields.Many2one( + 'res.users', + string='Exported by', + readonly=True, + ) + field_ids = fields.Many2many( + 'ir.model.fields', + string='Exported Fields', + readonly=True, + ) + record_ids = fields.Many2many( + 'ir.model.data', + string='Exported Records', + readonly=True, + ) + + @api.model + def log_export(self, recordset, field_names): + date = fields.Datetime.now() + model_name = recordset._name + model = self.env['ir.model'].search([('model', '=', model_name)]) + user = self.env.user + name_data = {'date': date, 'model': model.name, 'user': user.name} + name = '%(date)s / %(model)s / %(user)s' % name_data + exported_fields = self.env['ir.model.fields'].search([ + ('model', '=', model_name), + ('name', 'in', field_names), + ]) + records = self.env['ir.model.data'].search([ + ('model', '=', model_name), + ('res_id', 'in', recordset.ids), + ]) + export = self.create({ + 'name': name, + 'model_id': model.id, + 'field_ids': [(6, 0, exported_fields.ids)], + 'record_ids': [(6, 0, records.ids)], + 'user_id': user.id, + }) + export.sudo().post_notification() + return export + + @api.multi + def post_notification(self): + channel = self.env.ref('base_export_security.export_channel') + field_labels = ', '.join( + self.field_ids.mapped('field_description'), + ) + message_data = { + 'records': len(self.record_ids), + 'model': self.model_id.name, + 'user': self.user_id.name, + 'fields': field_labels, + } + message_body = _( + '%(records)d %(model)s records exported by %(user)s' + '.
Fields exported: %(fields)s' + ) % message_data + message = channel.message_post( + body=message_body, + message_type='notification', + subtype='mail.mt_comment', + ) + return message diff --git a/base_export_security/security/export_security.xml b/base_export_security/security/export_security.xml new file mode 100644 index 000000000..3c6f74fc4 --- /dev/null +++ b/base_export_security/security/export_security.xml @@ -0,0 +1,13 @@ + + + + + + Export Rights + + The user will be able to export data. + + + + diff --git a/base_export_security/security/ir.model.access.csv b/base_export_security/security/ir.model.access.csv new file mode 100644 index 000000000..83d81ceae --- /dev/null +++ b/base_export_security/security/ir.model.access.csv @@ -0,0 +1,2 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +export_rights,export rights,model_export_event,base_export_security.export_group,1,1,1,1 diff --git a/base_export_security/static/description/export_log.png b/base_export_security/static/description/export_log.png new file mode 100644 index 000000000..a8722e052 Binary files /dev/null and b/base_export_security/static/description/export_log.png differ diff --git a/base_export_security/static/description/export_menu_item_hidden.png b/base_export_security/static/description/export_menu_item_hidden.png new file mode 100644 index 000000000..8965a1316 Binary files /dev/null and b/base_export_security/static/description/export_menu_item_hidden.png differ diff --git a/base_export_security/static/description/export_notification.png b/base_export_security/static/description/export_notification.png new file mode 100644 index 000000000..bf9b6ee97 Binary files /dev/null and b/base_export_security/static/description/export_notification.png differ diff --git a/base_export_security/static/src/js/base_export_security.js b/base_export_security/static/src/js/base_export_security.js new file mode 100644 index 000000000..72e4ae35e --- /dev/null +++ b/base_export_security/static/src/js/base_export_security.js @@ -0,0 +1,35 @@ +/* Copyright 2017 LasLabs Inc. + * License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). */ + +odoo.define('base_export_security', function(require){ + 'use strict'; + + var Model = require('web.Model'); + var ListView = require('web.ListView'); + var core = require('web.core'); + + ListView.include({ + render_sidebar: function($node) { + var exportLabel = core._t('Export'); + var users = new Model('res.users'); + var res = this._super($node); + + if (this.sidebar && Object.prototype.hasOwnProperty.call(this.sidebar.items, 'other')) { + users.call('has_group', ['base_export_security.export_group']).then(function(result){ + if(!result){ + var filteredItems = this.sidebar.items.other.filter( + function(item){ + return item.label !== exportLabel; + } + ); + this.sidebar.items.other = filteredItems; + this.sidebar.redraw(); + } + }.bind(this)); + } + + return res; + } + }); + +}); diff --git a/base_export_security/static/tests/js/base_export_security.js b/base_export_security/static/tests/js/base_export_security.js new file mode 100755 index 000000000..54b12ef81 --- /dev/null +++ b/base_export_security/static/tests/js/base_export_security.js @@ -0,0 +1,71 @@ +/* Copyright 2017 LasLabs Inc. + * License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). */ + +odoo.define_section('base_export_security', + ['web.core', 'web.data', 'web.data_manager', 'web.ListView'], + function(test, mock) { + 'use strict'; + + function setup (authorization) { + mock.add('test.model:read', function () { + return [{ id: 1, a: 'foo', b: 'bar', c: 'baz' }]; + }); + + mock.add('res.users:has_group', function () { + return authorization; + }); + } + + function fields_view_get () { + return { + type: 'tree', + fields: { + a: {type: 'char', string: 'A'}, + b: {type: 'char', string: 'B'}, + c: {type: 'char', string: 'C'} + }, + arch: '' + }; + } + + function labelCount (exportLabel) { + var $fix = $('#qunit-fixture'); + var exportItems = $fix.find('.btn-group a[data-section="other"]').filter( + function(i, item){ + return item.text.trim() === exportLabel; + } + ); + + return exportItems.length; + } + + function renderView (data, data_manager, ListView) { + var $fix = $('#qunit-fixture'); + var dataset = new data.DataSetStatic(null, 'test.model', null, [1]); + var fields_view = data_manager._postprocess_fvg(fields_view_get()); + var listView = new ListView({}, dataset, fields_view, {sidebar: true}); + + listView.appendTo($fix).then(listView.render_sidebar($fix)); + } + + test('It should display the Export menu item to authorized users', + function(assert, core, data, data_manager, ListView) { + var exportLabel = core._t('Export'); + setup(true); + renderView(data, data_manager, ListView); + + assert.strictEqual(labelCount(exportLabel), 1); + } + ); + + test('It should not display the Export menu item to unauthorized users', + function(assert, core, data, data_manager, ListView) { + var exportLabel = core._t('Export'); + setup(false); + renderView(data, data_manager, ListView); + + assert.strictEqual(labelCount(exportLabel), 0); + } + ); + +}); diff --git a/base_export_security/tests/__init__.py b/base_export_security/tests/__init__.py new file mode 100644 index 000000000..92c7297ba --- /dev/null +++ b/base_export_security/tests/__init__.py @@ -0,0 +1,5 @@ +# -*- coding: utf-8 -*- +# Copyright 2017 LasLabs Inc. +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). + +from . import test_export diff --git a/base_export_security/tests/test_export.py b/base_export_security/tests/test_export.py new file mode 100644 index 000000000..f0b096ac2 --- /dev/null +++ b/base_export_security/tests/test_export.py @@ -0,0 +1,123 @@ +# -*- coding: utf-8 -*- +# Copyright 2017 LasLabs Inc. +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). + +import mock + +from odoo import _ +from odoo.exceptions import AccessError +from odoo.tests.common import HttpCase, TransactionCase + + +class TestExport(TransactionCase): + def setUp(self): + super(TestExport, self).setUp() + export_group = self.env.ref('base_export_security.export_group') + self.authorized_user = self.env['res.users'].create({ + 'login': 'exporttestuser', + 'partner_id': self.env['res.partner'].create({ + 'name': "Export Test User" + }).id, + 'groups_id': [(4, export_group.id, 0)], + }) + self.unauthorized_user = self.env['res.users'].create({ + 'login': 'unauthorizedexporttestuser', + 'partner_id': self.env['res.partner'].create({ + 'name': "Unauthorized Export Test User" + }).id, + }) + self.model_name = 'ir.module.module' + self.recordset = self.env[self.model_name].search([]) + self.field_names = ['name', 'id'] + self.model = self.env['ir.model'].search([ + ('model', '=', self.model_name), + ]) + self.records = self.env['ir.model.data'].search([ + ('model', '=', self.model_name), + ('res_id', 'in', self.recordset.ids), + ]) + self.fields = self.env['ir.model.fields'].search([ + ('model', '=', self.model_name), + ('name', 'in', self.field_names), + ]) + + def test_log_export(self): + """It should create a new Export record with correct data""" + log = self.env['export.event'].sudo(self.authorized_user).log_export( + self.recordset, + self.field_names, + ) + self.assertEqual( + [log.model_id, log.field_ids, log.record_ids, log.user_id], + [self.model, self.fields, self.records, self.authorized_user], + 'Log not created properly', + ) + + def test_log_export_posts_notification(self): + """It should call post_notification method""" + post_notification_mock = mock.MagicMock() + self.env['export.event']._patch_method( + 'post_notification', + post_notification_mock, + ) + self.env['export.event'].sudo(self.authorized_user).log_export( + self.recordset, + self.field_names, + ) + post_notification_mock.assert_called_once_with() + self.env['export.event']._revert_method('post_notification') + + def test_post_notification(self): + """It should post a notification with appropriate data + to the #data export channel""" + export = self.env['export.event'].create({ + 'model_id': self.model.id, + 'field_ids': [(4, self.fields.ids)], + 'record_ids': [(4, self.records.ids)], + 'user_id': self.authorized_user.id, + }) + message = export.sudo().post_notification() + field_labels = ', '.join( + self.fields.sorted().mapped('field_description'), + ) + message_data = { + 'records': len(self.records.ids), + 'model': self.model.name, + 'user': self.authorized_user.name, + 'fields': field_labels, + } + message_body = _( + '

%(records)d %(model)s records exported by %(user)s' + '.
Fields exported: %(fields)s

' + ) % message_data + self.assertEqual( + [message.body, message.message_type, message.model], + [message_body, 'notification', 'mail.channel'], + 'Message not posted properly', + ) + + def test_export_data_access(self): + """It should raise AccessError if user does not have export rights""" + with self.assertRaises(AccessError): + self.env[self.model_name].sudo( + self.unauthorized_user + ).export_data(self, None) + + def test_export_data_calls_log_export(self): + """It should call log_export if user has export rights""" + log_export_mock = mock.MagicMock() + self.env['export.event']._patch_method('log_export', log_export_mock) + model = self.env[self.model_name] + model.sudo(self.authorized_user).export_data(self.field_names) + log_export_mock.assert_called_once_with(model, self.field_names) + self.env['export.event']._revert_method('log_export') + + +class TestJS(HttpCase): + def test_export_visibility(self): + """Test visibility of export menu item""" + self.phantom_js( + "/web/tests?debug=assets&module=base_export_security", + "", + login="admin", + ) diff --git a/base_export_security/views/export_view.xml b/base_export_security/views/export_view.xml new file mode 100644 index 000000000..0c2363697 --- /dev/null +++ b/base_export_security/views/export_view.xml @@ -0,0 +1,90 @@ + + + + + + Export Log + export.event + +
+ + + + + + + + + + + + + + + + +
+
+
+ + + Export Logs + export.event + + + + + + + + + + + Export Logs Search + export.event + + + + + + + + + + + + + Export Logs + export.event + form + tree,form + + + + + + + +