Skip to content
Closed
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
1 change: 1 addition & 0 deletions vault/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from . import (
abstract_vault,
abstract_vault_field,
res_company,
res_config_settings,
res_users,
res_users_key,
Expand Down
37 changes: 37 additions & 0 deletions vault/models/res_company.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# © 2026 Nitrokey GmbH
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).

from odoo import _, api, fields, models
from odoo.exceptions import ValidationError


class ResCompany(models.Model):
_inherit = "res.company"

vault_custodian_ids = fields.Many2many(
"res.users",
"vault_company_custodian_rel",
"company_id",
"user_id",
string="Vault Custodians",
domain=[("has_vault_key", "=", True)],
help="Users configured here are automatically added to every newly "
"created vault and can not be removed from it. They keep access to the "
"end-to-end encrypted vaults, for example to recover the data in case "
"an employee leaves the company.",
)

@api.constrains("vault_custodian_ids")
def _check_vault_custodian_keys(self):
for company in self:
keyless = company.vault_custodian_ids.filtered(
lambda u: not u.has_vault_key
)
if keyless:
raise ValidationError(
_(
"The following users can not be vault custodians because "
"they have no vault keys: %s"
)
% ", ".join(keyless.mapped("display_name"))
)
5 changes: 5 additions & 0 deletions vault/models/res_config_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,8 @@ class ResConfigSettings(models.TransientModel):
group_vault_import = fields.Boolean(
"Import Vaults", implied_group="vault.group_vault_import"
)
vault_custodian_ids = fields.Many2many(
related="company_id.vault_custodian_ids",
string="Mandatory Custodians",
readonly=False,
)
19 changes: 19 additions & 0 deletions vault/models/res_users.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,31 @@ class ResUsers(models.Model):
store=False,
)
keys = fields.One2many("res.users.key", "user_id", readonly=True)
has_vault_key = fields.Boolean(
compute="_compute_has_vault_key",
search="_search_has_vault_key",
help="Whether the user has vault keys configured",
)
vault_right_ids = fields.One2many("vault.right", "user_id", readonly=True)
inbox_ids = fields.One2many("vault.inbox", "user_id")
inbox_enabled = fields.Boolean(default=True)
inbox_link = fields.Char(compute="_compute_inbox_link", readonly=True, store=False)
inbox_token = fields.Char(default=lambda self: uuid4(), readonly=True)

@api.depends("keys")
def _compute_has_vault_key(self):
for rec in self:
rec.has_vault_key = bool(rec.sudo().keys)

@api.model
def _search_has_vault_key(self, operator, value):
if operator not in ("=", "!=") or not isinstance(value, bool):
raise ValueError(self.env._("Unsupported search operator"))

users_with_keys = self.env["res.users.key"].sudo().search([]).mapped("user_id")
has_key = (operator == "=") == value
return [("id", "in" if has_key else "not in", users_with_keys.ids)]

@api.depends("keys", "keys.current")
def _compute_active_key(self):
for rec in self:
Expand Down
60 changes: 59 additions & 1 deletion vault/models/vault.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from uuid import uuid4

from odoo import _, api, fields, models
from odoo.exceptions import UserError

_logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -100,7 +101,7 @@ def _inverse_master_key(self):
rights.key = rec.master_key

def _get_default_rights(self):
return [
rights = [
(
0,
0,
Expand All @@ -114,6 +115,63 @@ def _get_default_rights(self):
)
]

custodians = self.env.company.sudo().vault_custodian_ids
for custodian in custodians:
if custodian.id == self.env.uid:
continue

rights.append(
(
0,
0,
{
"user_id": custodian.id,
"perm_create": False,
"perm_write": False,
"perm_delete": False,
"perm_share": True,
},
)
)

return rights

@api.model_create_multi
def create(self, vals_list):
records = super().create(vals_list)
records._enforce_custodians()
return records

def _enforce_custodians(self):
custodians = self.env.company.sudo().vault_custodian_ids
if not custodians:
return

required = custodians.filtered(lambda u: u.id != self.env.uid)
for rec in self:
custodian_rights = rec.right_ids.filtered(
lambda r, required=required: r.user_id in required
)
missing = required - custodian_rights.user_id
if missing:
raise UserError(
_(
"The following mandatory vault custodians must be shared "
"with the vault and can not be removed: %s"
)
% ", ".join(missing.mapped("display_name"))
)

without_share = custodian_rights.filtered(lambda r: not r.perm_share)
if without_share:
raise UserError(
_(
"The following mandatory vault custodians must have the "
"share permission: %s"
)
% ", ".join(without_share.user_id.mapped("display_name"))
)

def _log_entry(self, msg, state):
self.ensure_one()
return (
Expand Down
21 changes: 20 additions & 1 deletion vault/models/vault_right.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
# © 2021 Florian Kantelberg - initOS GmbH
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).

from odoo import api, fields, models
from odoo import _, api, fields, models
from odoo.exceptions import UserError


class VaultRight(models.Model):
Expand Down Expand Up @@ -63,6 +64,10 @@ class VaultRight(models.Model):
def _get_is_owner(self):
return self.env.user == self.vault_id.user_id

def _filtered_custodians(self):
custodians = self.env.company.sudo().vault_custodian_ids
return self.filtered(lambda r: r.user_id in custodians)

@api.depends("user_id")
def _compute_public_key(self):
for rec in self:
Expand Down Expand Up @@ -95,6 +100,15 @@ def create(self, vals_list):
return res

def write(self, values):
# Prevent revoking the share with a mandatory custodian
if not self.env.su and self._filtered_custodians():
if values.get("perm_share") is False:
raise UserError(
_("The share permission of a custodian can not be removed.")
)
if "user_id" in values:
raise UserError(_("The user of a custodian can not be changed."))

res = super().write(values)
perms = ["perm_write", "perm_delete", "perm_share", "perm_create"]
if any(x in values for x in perms):
Expand All @@ -103,6 +117,11 @@ def write(self, values):
return res

def unlink(self):
if not self.env.su and self._filtered_custodians():
raise UserError(
_("A mandatory custodian can not be removed from the vault.")
)

for rec in self:
rec.vault_id.log_info(f"Removed user {self.user_id.display_name}")
rec.vault_id.reencrypt_required = True
Expand Down
14 changes: 14 additions & 0 deletions vault/readme/DESCRIPTION.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,20 @@ entries more easily.
This modules requires a secure context for the browser to work properly
and therefore HTTPS support is required.

Vault Custodians can be configured in the general settings. These users
are automatically added to every newly created vault and can not be
removed from it. They keep access to the end-to-end encrypted vaults,
for example to recover the data in case an employee leaves the company.
Custodians receive read and share permissions by default; the owner can
additionally grant them write and delete permissions.

The custodian protection only applies while a user is configured as a
custodian. Removing a user from the setting, or the user invalidating
their own keys, drops their access again. Because the encryption happens
in the browser, a custodian added to a vault created outside the browser
(e.g. by import or another module) only receives a usable key once a
user holding the master key re-shares or re-encrypts the vault.

The [vault-recovery](https://github.com/fkantelberg/vault-recovery)
project focuses on disaster recovery in case of an incident to recover
secrets from old database backups or old exports.
1 change: 1 addition & 0 deletions vault/tests/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

from . import (
test_controller,
test_custodian,
test_log,
test_rights,
test_user,
Expand Down
Loading
Loading