diff --git a/vault/__manifest__.py b/vault/__manifest__.py index 976f64ef03..2e6b872971 100644 --- a/vault/__manifest__.py +++ b/vault/__manifest__.py @@ -12,9 +12,10 @@ "category": "Vault", "depends": ["base_setup", "web"], "data": [ + "security/vault_security.xml", "security/ir.model.access.csv", "security/ir_rule.xml", - "security/vault_security.xml", + "views/vault_access_log_views.xml", "views/res_config_settings_views.xml", "views/res_users_views.xml", "views/vault_entry_views.xml", diff --git a/vault/models/__init__.py b/vault/models/__init__.py index d9d3e40ebe..8f31fd59ff 100644 --- a/vault/models/__init__.py +++ b/vault/models/__init__.py @@ -16,4 +16,5 @@ vault_log, vault_right, vault_tag, + vault_access_log, ) diff --git a/vault/models/vault_access_log.py b/vault/models/vault_access_log.py new file mode 100644 index 0000000000..437b98b53e --- /dev/null +++ b/vault/models/vault_access_log.py @@ -0,0 +1,53 @@ +# © 2024 Odoo Community Association (OCA) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import api, fields, models + + +class VaultAccessLog(models.Model): + _name = "vault.access.log" + _description = "Vault Access Log" + _order = "accessed_at DESC" + + secret_id = fields.Many2one( + "vault.entry", + string="Secret", + required=True, + ondelete="cascade", + index=True, + help="The secret that was accessed", + ) + user_id = fields.Many2one( + "res.users", + string="User", + required=True, + index=True, + default=lambda self: self.env.user, + help="The user who accessed the secret", + ) + accessed_at = fields.Datetime( + required=True, + default=fields.Datetime.now, + help="When the secret was accessed", + ) + ip_address = fields.Char( + string="IP Address", + help="IP address the access originated from, resolved from the HTTP request", + ) + access_type = fields.Selection( + [ + ("reveal", "Reveal"), + ("copy", "Copy"), + ("export", "Export"), + ], + required=True, + default="reveal", + help="Type of access performed on the secret", + ) + + @api.depends("secret_id", "user_id", "access_type", "accessed_at") + def _compute_display_name(self): + for log in self: + secret = log.secret_id.display_name or "" + user = log.user_id.display_name or "" + log.display_name = f"{secret} - {user} ({log.access_type or ''})" diff --git a/vault/models/vault_entry.py b/vault/models/vault_entry.py index 8fee29213b..5b27c33d01 100644 --- a/vault/models/vault_entry.py +++ b/vault/models/vault_entry.py @@ -2,12 +2,14 @@ # Copyright 2022 Tecnativa - Víctor Martínez # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +import json import logging from datetime import datetime from uuid import uuid4 from odoo import _, api, fields, models from odoo.exceptions import ValidationError +from odoo.http import request _logger = logging.getLogger(__name__) @@ -32,6 +34,9 @@ class VaultEntry(models.Model): field_ids = fields.One2many("vault.field", "entry_id", "Fields") file_ids = fields.One2many("vault.file", "entry_id", "Files") log_ids = fields.One2many("vault.log", "entry_id", "Log", readonly=True) + access_log_ids = fields.One2many( + "vault.access.log", "secret_id", "Access Logs", readonly=True + ) perm_user = fields.Many2one(related="vault_id.perm_user", store=False) allowed_read = fields.Boolean(related="vault_id.allowed_read", store=False) @@ -98,6 +103,46 @@ def search_read(self, domain=None, fields=None, offset=0, limit=None, order=None domain=domain, fields=fields, offset=offset, limit=limit, order=order ) + def action_log_access(self, access_type="reveal"): + """Record a read/access event on a secret for auditing (NIS2 / ISO 27001). + + Meant to be called over RPC from the frontend right after a secret is + revealed, copied or exported. Only the record id and the access type + cross the wire -- never the decrypted plaintext. The originating IP is + resolved server-side from the HTTP request, not trusted from the client. + """ + self.ensure_one() + + log_model = self.env["vault.access.log"] + valid_types = dict(log_model._fields["access_type"].selection) + if access_type not in valid_types: + raise ValidationError(_("Invalid access type: %s", access_type)) + + ip_address = request.httprequest.remote_addr if request else "" + + log_model.sudo().create( + { + "secret_id": self.id, + "user_id": self.env.user.id, + "access_type": access_type, + "ip_address": ip_address, + } + ) + + # Structured single-line JSON so SIEM collectors (Syslog, Datadog, ELK) + # can parse the event straight from stdout. + _logger.info( + json.dumps( + { + "event": "vault_access", + "user_id": self.env.user.id, + "secret_id": self.id, + "ip": ip_address, + "type": access_type, + } + ) + ) + def copy_data(self, default=None): self.ensure_one() diff --git a/vault/security/ir.model.access.csv b/vault/security/ir.model.access.csv index 0863b98aa8..f18f4dc41b 100644 --- a/vault/security/ir.model.access.csv +++ b/vault/security/ir.model.access.csv @@ -14,3 +14,5 @@ access_vault_send_wizard,access_vault_send_wizard,model_vault_send_wizard,base.g access_vault_store_wizard,access_vault_store_wizard,model_vault_store_wizard,base.group_user,1,1,1,1 access_vault_tag,access_vault_tag,model_vault_tag,base.group_user,1,1,1,1 access_vault_users_key,access_res_users_key,model_res_users_key,base.group_user,1,1,1,1 +access_vault_access_log_user,vault.access.log.user,model_vault_access_log,base.group_user,1,0,0,0 +access_vault_access_log_manager,vault.access.log.manager,model_vault_access_log,vault.group_vault_manager,1,0,0,0 diff --git a/vault/security/ir_rule.xml b/vault/security/ir_rule.xml index 9ffb2a1486..335ed232af 100644 --- a/vault/security/ir_rule.xml +++ b/vault/security/ir_rule.xml @@ -108,4 +108,26 @@ + + + vault.access.log.read.own + + [('user_id', '=', user.id)] + + + + + + + + + vault.access.log.read.all + + [(1, '=', 1)] + + + + + + diff --git a/vault/security/vault_security.xml b/vault/security/vault_security.xml index a810f29538..b0f3734c27 100644 --- a/vault/security/vault_security.xml +++ b/vault/security/vault_security.xml @@ -10,4 +10,9 @@ + + Vault Manager + + + diff --git a/vault/static/src/backend/fields/vault_access_log.esm.js b/vault/static/src/backend/fields/vault_access_log.esm.js new file mode 100644 index 0000000000..cf8a3ac639 --- /dev/null +++ b/vault/static/src/backend/fields/vault_access_log.esm.js @@ -0,0 +1,42 @@ +// © 2024 Odoo Community Association (OCA) +// License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +import {VaultField} from "./vault_field.esm"; +import {patch} from "@web/core/utils/patch"; +import {rpc} from "@web/core/network/rpc"; + +// Audit every successful reveal/copy of a secret for NIS2 / ISO 27001. +// Only the record id and the access type are sent to the server -- never the +// decrypted plaintext. The originating IP is resolved server-side from the +// HTTP request, so it is intentionally not provided by the browser. +const logVaultAccess = async (resId, accessType) => { + if (!resId) { + return; + } + try { + await rpc("/web/dataset/call_kw/vault.entry/action_log_access", { + model: "vault.entry", + method: "action_log_access", + args: [[resId], accessType], + kwargs: {}, + }); + } catch { + // Auditing must never break the user-facing action. + } +}; + +patch(VaultField.prototype, { + async _onShowValue(ev) { + const wasDecrypted = this.state.decrypted; + await super._onShowValue(ev); + // Only log the transition into the revealed state, not hiding it again. + if (!wasDecrypted && this.state.decrypted) { + await logVaultAccess(this.props.record.resId, "reveal"); + } + }, + + async _onCopyValue(ev) { + await super._onCopyValue(ev); + await logVaultAccess(this.props.record.resId, "copy"); + }, +}); diff --git a/vault/tests/__init__.py b/vault/tests/__init__.py index 59ea2f2ef1..8cf56fbeff 100644 --- a/vault/tests/__init__.py +++ b/vault/tests/__init__.py @@ -2,6 +2,7 @@ # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). from . import ( + test_access_log, test_controller, test_log, test_rights, diff --git a/vault/tests/test_access_log.py b/vault/tests/test_access_log.py new file mode 100644 index 0000000000..1f178fcaf6 --- /dev/null +++ b/vault/tests/test_access_log.py @@ -0,0 +1,80 @@ +# © 2024 Odoo Community Association (OCA) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo.exceptions import AccessError, ValidationError + +from odoo.addons.base.tests.common import BaseCommon + + +class TestAccessLog(BaseCommon): + def setUp(self): + super().setUp() + self.vault = self.env["vault"].create({"name": "Vault"}) + self.entry = self.env["vault.entry"].create( + {"vault_id": self.vault.id, "name": "Entry"} + ) + group_user = self.env.ref("base.group_user") + self.user_a = self.env["res.users"].create( + { + "name": "Vault User A", + "login": "vault_user_a", + "groups_id": [(6, 0, [group_user.id])], + } + ) + self.user_b = self.env["res.users"].create( + { + "name": "Vault User B", + "login": "vault_user_b", + "groups_id": [(6, 0, [group_user.id])], + } + ) + + def test_action_log_access_creates_log(self): + self.entry.action_log_access("reveal") + logs = self.env["vault.access.log"].search([("secret_id", "=", self.entry.id)]) + self.assertEqual(len(logs), 1) + self.assertEqual(logs.access_type, "reveal") + self.assertEqual(logs.user_id, self.env.user) + # No HTTP request in tests -> IP resolves to empty, never crashes. + self.assertFalse(logs.ip_address) + + def test_invalid_access_type_rejected(self): + with self.assertRaises(ValidationError): + self.entry.action_log_access("tamper") + + def test_logs_visible_only_to_owner(self): + Log = self.env["vault.access.log"].sudo() + Log.create( + { + "secret_id": self.entry.id, + "user_id": self.user_a.id, + "access_type": "reveal", + } + ) + Log.create( + { + "secret_id": self.entry.id, + "user_id": self.user_b.id, + "access_type": "copy", + } + ) + logs_a = self.env["vault.access.log"].with_user(self.user_a).search([]) + self.assertEqual(len(logs_a), 1) + self.assertEqual(logs_a.user_id, self.user_a) + + def test_logs_are_immutable_for_users(self): + log = ( + self.env["vault.access.log"] + .sudo() + .create( + { + "secret_id": self.entry.id, + "user_id": self.user_a.id, + "access_type": "reveal", + } + ) + ) + with self.assertRaises(AccessError): + log.with_user(self.user_a).write({"access_type": "copy"}) + with self.assertRaises(AccessError): + log.with_user(self.user_a).unlink() diff --git a/vault/views/menuitems.xml b/vault/views/menuitems.xml index cc05694426..0a0cfa13a3 100644 --- a/vault/views/menuitems.xml +++ b/vault/views/menuitems.xml @@ -62,4 +62,11 @@ action="action_vault_right" sequence="50" /> + diff --git a/vault/views/vault_access_log_views.xml b/vault/views/vault_access_log_views.xml new file mode 100644 index 0000000000..39913de716 --- /dev/null +++ b/vault/views/vault_access_log_views.xml @@ -0,0 +1,92 @@ + + + + vault.access.log.tree + vault.access.log + + + + + + + + + + + + + vault.access.log.search + vault.access.log + + + + + + + + + + + + + + + + + + + + + + vault.access.log.form + vault.access.log + +
+ + + + + + + + + +
+
+
+ + + Access Logs + vault.access.log + list,form + + +
diff --git a/vault/views/vault_entry_views.xml b/vault/views/vault_entry_views.xml index debf49963d..d3d6f8aaba 100644 --- a/vault/views/vault_entry_views.xml +++ b/vault/views/vault_entry_views.xml @@ -166,6 +166,9 @@ + + +