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
126 changes: 126 additions & 0 deletions vault_portal/README.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
============
Vault Portal
============

..
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!! This file is generated by oca-gen-addon-readme !!
!! changes will be overwritten. !!
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!! source digest: sha256:27a0f61908b59d809f7f5183639e02d495844f84d333e054a55b0deed6947c5e
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!

.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png
:target: https://odoo-community.org/page/development-status
:alt: Beta
.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png
:target: http://www.gnu.org/licenses/agpl-3.0-standalone.html
:alt: License: AGPL-3
.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fserver--auth-lightgray.png?logo=github
:target: https://github.com/OCA/server-auth/tree/18.0/vault_portal
:alt: OCA/server-auth
.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png
:target: https://translation.odoo-community.org/projects/server-auth-18-0/server-auth-18-0-vault_portal
:alt: Translate me on Weblate
.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png
:target: https://runboat.odoo-community.org/builds?repo=OCA/server-auth&target_branch=18.0
:alt: Try me on Runboat

|badge1| |badge2| |badge3| |badge4| |badge5|

Exposes vaults from the ``vault`` module to portal contacts
(``base.group_portal``), individually end-to-end encrypted like internal
users: each contact holds their own key pair, and the vault's master key
is re-wrapped for them.

Portal contacts can view the entries of vaults shared with them, and,
where granted per contact, edit existing field values, add new fields,
create new entries, and edit an entry's URL and expiry date. The vault
itself, entry names, and entry deletion remain read-only from the
portal. Entry tags are shown, read-only.

Entries can be searched by name, URL, and tag, and filtered by
active/expired status. A contact can also invalidate their own key pair
from the portal.

An optional MFA policy (Settings > Vault) can require two-factor
authentication for portal contacts before granting write access, or
before granting any access at all.

**Table of contents**

.. contents::
:local:

Usage
=====

1. Grant portal access to the contact (partner form, "Grant portal
access").
2. The contact visits ``/my/vaults`` and clicks "Set up my key" there.
This works even with zero vaults shared yet: it generates their
personal key pair, a prerequisite for step 3.
3. A technician adds a line in the desired vault's rights
(``right_ids``) for the contact's user, with the desired permissions.
Only contacts who already completed step 2 can be selected.

To revoke access, remove the ``vault.right`` line; the vault is flagged
for key rotation.

A contact can invalidate their own key pair from ``/my/vaults``
("Invalidate my key") if they lose their master password - this revokes
all their existing vault access, exactly like the backend's own
"Invalidate private key" action; a technician must then re-share access
with their new key.

The MFA policy is set in Settings > Vault > "Portal Two-Factor
Authentication Policy" (``none`` / ``write`` / ``read``).

Known issues / Roadmap
======================

- Read-only view of ``vault.log`` for portal contacts.
- Dedicated inactivity timeout for the portal vault session.
- Portal renaming of ``vault.entry``, deletion of entries and fields.
- Frontend test for the portal home tile visibility.
- No "Add an entry" control when a vault has zero entries yet.

Bug Tracker
===========

Bugs are tracked on `GitHub Issues <https://github.com/OCA/server-auth/issues>`_.
In case of trouble, please check there if your issue has already been reported.
If you spotted it first, help us to smash it by providing a detailed and welcomed
`feedback <https://github.com/OCA/server-auth/issues/new?body=module:%20vault_portal%0Aversion:%2018.0%0A%0A**Steps%20to%20reproduce**%0A-%20...%0A%0A**Current%20behavior**%0A%0A**Expected%20behavior**>`_.

Do not contact contributors directly about support or help with technical issues.

Credits
=======

Authors
-------

* INVITU

Contributors
------------

- Cyril VINH-TUNG <<cyril@invitu.com>>

Maintainers
-----------

This module is maintained by the OCA.

.. image:: https://odoo-community.org/logo.png
:alt: Odoo Community Association
:target: https://odoo-community.org

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.

This module is part of the `OCA/server-auth <https://github.com/OCA/server-auth/tree/18.0/vault_portal>`_ project on GitHub.

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.
5 changes: 5 additions & 0 deletions vault_portal/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Copyright 2026 INVITU (<https://www.invitu.com>)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).

from . import controllers
from . import models
29 changes: 29 additions & 0 deletions vault_portal/__manifest__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# Copyright 2026 INVITU (<https://www.invitu.com>)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).

{
"name": "Vault Portal",
"summary": "Portal access to shared vaults, per-user E2E encrypted",
"license": "AGPL-3",
"version": "18.0.1.0.0",
"website": "https://github.com/OCA/server-auth",
"author": "INVITU, Odoo Community Association (OCA)",
"category": "Vault",
"depends": ["vault", "portal", "auth_totp"],
"data": [
"security/ir.model.access.csv",
"security/ir_rule.xml",
"views/portal_templates.xml",
"views/res_config_settings_views.xml",
],
"assets": {
"web.assets_frontend": [
"vault/static/src/backend/vault.esm.js",

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

vault_utils are missing.

"vault_portal/static/src/frontend/vault_key_manager.esm.js",
"vault_portal/static/src/frontend/vault_detail.esm.js",
"vault_portal/static/src/xml/vault_key_manager.xml",
"vault_portal/static/src/xml/vault_detail.xml",
"vault_portal/static/src/scss/portal.scss",
],
},
}
4 changes: 4 additions & 0 deletions vault_portal/controllers/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# Copyright 2026 INVITU (<https://www.invitu.com>)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).

from . import portal
169 changes: 169 additions & 0 deletions vault_portal/controllers/portal.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
# Copyright 2026 INVITU (<https://www.invitu.com>)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).

import json

from odoo import _
from odoo.exceptions import AccessError, UserError
from odoo.http import request, route

from odoo.addons.portal.controllers.portal import CustomerPortal


class VaultPortal(CustomerPortal):
def _prepare_home_portal_values(self, counters):
values = super()._prepare_home_portal_values(counters)
if "vault_count" in counters:
values["vault_count"] = request.env["vault"].search_count([])
return values

def _vault_portal_base_values(self):
values = self._prepare_portal_layout_values()
values.update(
{
"has_keys": bool(request.env.user.active_key),
"page_name": "vaults",
}
)
return values

def _vault_portal_searchbar_filters(self):
return {
"all": {"label": _("All"), "domain": []},
"active": {"label": _("Active"), "domain": [("expired", "=", False)]},
"expired": {"label": _("Expired"), "domain": [("expired", "=", True)]},
}

def _get_vault_or_404(self, vault_id):
vault = request.env["vault"].browse(vault_id).exists()
if not vault:
raise request.not_found()
try:
vault.check_access("read")
except AccessError:
raise request.not_found() from None
return vault

def _get_vault_record(self, vault, model, record_id, label):
record = request.env[model].browse(record_id).exists()
if not record or record.vault_id != vault:
raise UserError(_("%s not found in this vault.") % label)
return record

def _field_props(self, field):
return {
"id": field.id,
"name": field.name,
"value": field.value,
"iv": field.iv,
"allowedWrite": field.allowed_write,
}

def _entry_props(self, entry):
return {
"id": entry.id,
"name": entry.name,
"url": entry.url or "",
"expireDate": entry.expire_date
and entry.expire_date.date().isoformat()
or "",
"tags": entry.tags.mapped("name"),
"expired": entry.expired,
"allowedWrite": entry.allowed_write,
"allowedCreate": entry.allowed_create,
"parentId": entry.parent_id.id or 0,
"parentName": entry.parent_id.complete_name if entry.parent_id else "",
"fields": [self._field_props(f) for f in entry.field_ids],
}

def _vault_props(self, vault, entries):
return {
"vaultId": vault.id,
"masterKey": vault.master_key or "",
"allowedCreate": vault.allowed_create,
"entries": [self._entry_props(e) for e in entries],
}

@route(["/my/vaults"], type="http", auth="user", website=True)
def portal_my_vaults(self, **kwargs):
values = self._vault_portal_base_values()
values["vaults"] = request.env["vault"].search([])
values["key_manager_props"] = json.dumps({"hasKeys": values["has_keys"]})
return request.render("vault_portal.portal_my_vaults", values)

@route(["/my/vaults/<int:vault_id>"], type="http", auth="user", website=True)
def portal_vault_detail(self, vault_id, filterby=None, **kwargs):
vault = self._get_vault_or_404(vault_id)

searchbar_filters = self._vault_portal_searchbar_filters()
if not filterby or filterby not in searchbar_filters:
filterby = "all"
domain = [("vault_id", "=", vault.id)] + searchbar_filters[filterby]["domain"]

entries = (
request.env["vault.entry"]
.search(domain)
# Hide pure organizational folders (no field of their own,
# but with children shown as their own group) - never hide
# a genuinely empty leaf entry (no field, no child yet),
# which would otherwise make a freshly created entry vanish
# with no way to add a first field to it.
.filtered(lambda e: e.field_ids or not e.child_ids)
.sorted(key=lambda e: (e.parent_id.id, e.complete_name))
)

values = self._vault_portal_base_values()
values.update(
{
"vault": vault,
"searchbar_filters": searchbar_filters,
"filterby": filterby,
"default_url": f"/my/vaults/{vault.id}",
"vault_props": json.dumps(self._vault_props(vault, entries)),
}
)
return request.render("vault_portal.portal_vault_detail", values)

@route(["/my/vaults/<int:vault_id>/entries"], type="json", auth="user")
def portal_vault_create_entry(self, vault_id, name, parent_id=0, **kwargs):
vault = self._get_vault_or_404(vault_id)
vals = {"vault_id": vault.id, "name": name}
if parent_id:
vals["parent_id"] = parent_id
entry = request.env["vault.entry"].create(vals)
return self._entry_props(entry)

@route(
["/my/vaults/<int:vault_id>/entries/<int:entry_id>"],
type="json",
auth="user",
)
def portal_vault_write_entry(self, vault_id, entry_id, **vals):
vault = self._get_vault_or_404(vault_id)
entry = self._get_vault_record(vault, "vault.entry", entry_id, _("Entry"))
entry.write(vals)
return self._entry_props(entry)

@route(
["/my/vaults/<int:vault_id>/entries/<int:entry_id>/fields"],
type="json",
auth="user",
)
def portal_vault_create_field(self, vault_id, entry_id, name, value, iv, **kwargs):
vault = self._get_vault_or_404(vault_id)
entry = self._get_vault_record(vault, "vault.entry", entry_id, _("Entry"))
field = request.env["vault.field"].create(
{"entry_id": entry.id, "name": name, "value": value, "iv": iv}
)
return self._field_props(field)

@route(
["/my/vaults/<int:vault_id>/fields/<int:field_id>"],
type="json",
auth="user",
)
def portal_vault_write_field(self, vault_id, field_id, value, iv, **kwargs):
vault = self._get_vault_or_404(vault_id)
field = self._get_vault_record(vault, "vault.field", field_id, _("Field"))
field.write({"value": value, "iv": iv})
return self._field_props(field)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you also want to support files? I don't see it anywhere and not even listed in the roadmap.

7 changes: 7 additions & 0 deletions vault_portal/models/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# Copyright 2026 INVITU (<https://www.invitu.com>)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).

from . import res_config_settings
from . import res_users
from . import vault_entry
from . import vault_right
30 changes: 30 additions & 0 deletions vault_portal/models/res_config_settings.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# Copyright 2026 INVITU (<https://www.invitu.com>)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).

from odoo import fields, models


class ResConfigSettings(models.TransientModel):
_inherit = "res.config.settings"

vault_portal_mfa_policy = fields.Selection(
[
("none", "No constraint"),
("write", "Require 2FA for write/create access"),
("read", "Require 2FA for any portal access"),
],
string="Portal contacts MFA policy",
config_parameter="vault_portal.mfa_policy",
default="none",
help=(
"No constraint: two-factor authentication is not required.\n"
"Require 2FA for write/create access: a portal contact must "
"have 2FA enabled before being granted write or create access "
"to a vault; disabling 2FA afterwards downgrades their access "
"back to read-only.\n"
"Require 2FA for any portal access: a portal contact must have "
"2FA enabled before being granted ANY access to a vault, "
"including read-only; disabling 2FA afterwards immediately "
"revokes all their vault access."
),
)
Loading
Loading