Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion vault/__manifest__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions vault/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,5 @@
vault_log,
vault_right,
vault_tag,
vault_access_log,
)
53 changes: 53 additions & 0 deletions vault/models/vault_access_log.py
Original file line number Diff line number Diff line change
@@ -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 ''})"
45 changes: 45 additions & 0 deletions vault/models/vault_entry.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)

Expand All @@ -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)
Expand Down Expand Up @@ -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()

Expand Down
2 changes: 2 additions & 0 deletions vault/security/ir.model.access.csv
Original file line number Diff line number Diff line change
Expand Up @@ -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
22 changes: 22 additions & 0 deletions vault/security/ir_rule.xml
Original file line number Diff line number Diff line change
Expand Up @@ -108,4 +108,26 @@
<field name="perm_unlink" eval="1" />
<field name="perm_read" eval="1" />
</record>

<record id="vault_access_log_read_own" model="ir.rule">
<field name="name">vault.access.log.read.own</field>
<field name="model_id" ref="vault.model_vault_access_log" />
<field name="domain_force">[('user_id', '=', user.id)]</field>
<field name="groups" eval="[(4, ref('base.group_user'))]" />
<field name="perm_read" eval="1" />
<field name="perm_create" eval="0" />
<field name="perm_write" eval="0" />
<field name="perm_unlink" eval="0" />
</record>

<record id="vault_access_log_read_all" model="ir.rule">
<field name="name">vault.access.log.read.all</field>
<field name="model_id" ref="vault.model_vault_access_log" />
<field name="domain_force">[(1, '=', 1)]</field>
<field name="groups" eval="[(4, ref('vault.group_vault_manager'))]" />
<field name="perm_read" eval="1" />
<field name="perm_create" eval="0" />
<field name="perm_write" eval="0" />
<field name="perm_unlink" eval="0" />
</record>
</odoo>
5 changes: 5 additions & 0 deletions vault/security/vault_security.xml
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,9 @@
<field name="category_id" ref="base.module_category_hidden" />
<field name="users" eval="[(4, ref('base.user_admin'))]" />
</record>
<record id="group_vault_manager" model="res.groups">
<field name="name">Vault Manager</field>
<field name="category_id" ref="base.module_category_hidden" />
<field name="users" eval="[(4, ref('base.user_admin'))]" />
</record>
</odoo>
42 changes: 42 additions & 0 deletions vault/static/src/backend/fields/vault_access_log.esm.js
Original file line number Diff line number Diff line change
@@ -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");
},
});
1 change: 1 addition & 0 deletions vault/tests/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
80 changes: 80 additions & 0 deletions vault/tests/test_access_log.py
Original file line number Diff line number Diff line change
@@ -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()
7 changes: 7 additions & 0 deletions vault/views/menuitems.xml
Original file line number Diff line number Diff line change
Expand Up @@ -62,4 +62,11 @@
action="action_vault_right"
sequence="50"
/>
<menuitem
id="menu_vault_access_log"
groups="vault.group_vault_manager"
parent="vault.menu_vault"
action="action_vault_access_log"
sequence="60"
/>
</odoo>
Loading
Loading