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