mirror of
https://github.com/bringout/oca-server-auth.git
synced 2026-04-18 22:52:00 +02:00
Initial commit: OCA Server Auth packages (29 packages)
This commit is contained in:
commit
3ed80311c4
1325 changed files with 127292 additions and 0 deletions
47
odoo-bringout-oca-server-auth-vault/README.md
Normal file
47
odoo-bringout-oca-server-auth-vault/README.md
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
# Vault
|
||||
|
||||
Odoo addon: vault
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
pip install odoo-bringout-oca-server-auth-vault
|
||||
```
|
||||
|
||||
## Dependencies
|
||||
|
||||
This addon depends on:
|
||||
- base_setup
|
||||
- web
|
||||
|
||||
## Manifest Information
|
||||
|
||||
- **Name**: Vault
|
||||
- **Version**: 16.0.1.0.3
|
||||
- **Category**: Vault
|
||||
- **License**: AGPL-3
|
||||
- **Installable**: False
|
||||
|
||||
## Source
|
||||
|
||||
Based on [OCA/server-auth](https://github.com/OCA/server-auth) branch 16.0, addon `vault`.
|
||||
|
||||
## License
|
||||
|
||||
This package maintains the original AGPL-3 license from the upstream Odoo project.
|
||||
|
||||
## Documentation
|
||||
|
||||
- Overview: doc/OVERVIEW.md
|
||||
- Architecture: doc/ARCHITECTURE.md
|
||||
- Models: doc/MODELS.md
|
||||
- Controllers: doc/CONTROLLERS.md
|
||||
- Wizards: doc/WIZARDS.md
|
||||
- Reports: doc/REPORTS.md
|
||||
- Security: doc/SECURITY.md
|
||||
- Install: doc/INSTALL.md
|
||||
- Usage: doc/USAGE.md
|
||||
- Configuration: doc/CONFIGURATION.md
|
||||
- Dependencies: doc/DEPENDENCIES.md
|
||||
- Troubleshooting: doc/TROUBLESHOOTING.md
|
||||
- FAQ: doc/FAQ.md
|
||||
32
odoo-bringout-oca-server-auth-vault/doc/ARCHITECTURE.md
Normal file
32
odoo-bringout-oca-server-auth-vault/doc/ARCHITECTURE.md
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
# Architecture
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
U[Users] -->|HTTP| V[Views and QWeb Templates]
|
||||
V --> C[Controllers]
|
||||
V --> W[Wizards – Transient Models]
|
||||
C --> M[Models and ORM]
|
||||
W --> M
|
||||
M --> R[Reports]
|
||||
DX[Data XML] --> M
|
||||
S[Security – ACLs and Groups] -. enforces .-> M
|
||||
|
||||
subgraph Vault Module - vault
|
||||
direction LR
|
||||
M:::layer
|
||||
W:::layer
|
||||
C:::layer
|
||||
V:::layer
|
||||
R:::layer
|
||||
S:::layer
|
||||
DX:::layer
|
||||
end
|
||||
|
||||
classDef layer fill:#eef8ff,stroke:#6ea8fe,stroke-width:1px
|
||||
```
|
||||
|
||||
Notes
|
||||
- Views include tree/form/kanban templates and report templates.
|
||||
- Controllers provide website/portal routes when present.
|
||||
- Wizards are UI flows implemented with `models.TransientModel`.
|
||||
- Data XML loads data/demo records; Security defines groups and access.
|
||||
3
odoo-bringout-oca-server-auth-vault/doc/CONFIGURATION.md
Normal file
3
odoo-bringout-oca-server-auth-vault/doc/CONFIGURATION.md
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# Configuration
|
||||
|
||||
Refer to Odoo settings for vault. Configure related models, access rights, and options as needed.
|
||||
17
odoo-bringout-oca-server-auth-vault/doc/CONTROLLERS.md
Normal file
17
odoo-bringout-oca-server-auth-vault/doc/CONTROLLERS.md
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
# Controllers
|
||||
|
||||
HTTP routes provided by this module.
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant U as User/Client
|
||||
participant C as Module Controllers
|
||||
participant O as ORM/Views
|
||||
|
||||
U->>C: HTTP GET/POST (routes)
|
||||
C->>O: ORM operations, render templates
|
||||
O-->>U: HTML/JSON/PDF
|
||||
```
|
||||
|
||||
Notes
|
||||
- See files in controllers/ for route definitions.
|
||||
6
odoo-bringout-oca-server-auth-vault/doc/DEPENDENCIES.md
Normal file
6
odoo-bringout-oca-server-auth-vault/doc/DEPENDENCIES.md
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
# Dependencies
|
||||
|
||||
This addon depends on:
|
||||
|
||||
- [base_setup](../../odoo-bringout-oca-ocb-base_setup)
|
||||
- [web](../../odoo-bringout-oca-ocb-web)
|
||||
4
odoo-bringout-oca-server-auth-vault/doc/FAQ.md
Normal file
4
odoo-bringout-oca-server-auth-vault/doc/FAQ.md
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
# FAQ
|
||||
|
||||
- Q: Which Odoo version? A: 16.0 (OCA/OCB packaged).
|
||||
- Q: How to enable? A: Start server with --addon vault or install in UI.
|
||||
7
odoo-bringout-oca-server-auth-vault/doc/INSTALL.md
Normal file
7
odoo-bringout-oca-server-auth-vault/doc/INSTALL.md
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
# Install
|
||||
|
||||
```bash
|
||||
pip install odoo-bringout-oca-server-auth-vault"
|
||||
# or
|
||||
uv pip install odoo-bringout-oca-server-auth-vault"
|
||||
```
|
||||
25
odoo-bringout-oca-server-auth-vault/doc/MODELS.md
Normal file
25
odoo-bringout-oca-server-auth-vault/doc/MODELS.md
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
# Models
|
||||
|
||||
Detected core models and extensions in vault.
|
||||
|
||||
```mermaid
|
||||
classDiagram
|
||||
class res_users_key
|
||||
class vault
|
||||
class vault_abstract
|
||||
class vault_abstract_field
|
||||
class vault_entry
|
||||
class vault_field
|
||||
class vault_file
|
||||
class vault_inbox
|
||||
class vault_inbox_log
|
||||
class vault_log
|
||||
class vault_right
|
||||
class vault_tag
|
||||
class res_config_settings
|
||||
class res_users
|
||||
```
|
||||
|
||||
Notes
|
||||
- Classes show model technical names; fields omitted for brevity.
|
||||
- Items listed under _inherit are extensions of existing models.
|
||||
6
odoo-bringout-oca-server-auth-vault/doc/OVERVIEW.md
Normal file
6
odoo-bringout-oca-server-auth-vault/doc/OVERVIEW.md
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
# Overview
|
||||
|
||||
Packaged Odoo addon: vault. Provides features documented in upstream Odoo 16 under this addon.
|
||||
|
||||
- Source: OCA/OCB 16.0, addon vault
|
||||
- License: LGPL-3
|
||||
3
odoo-bringout-oca-server-auth-vault/doc/REPORTS.md
Normal file
3
odoo-bringout-oca-server-auth-vault/doc/REPORTS.md
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# Reports
|
||||
|
||||
This module does not define custom reports.
|
||||
46
odoo-bringout-oca-server-auth-vault/doc/SECURITY.md
Normal file
46
odoo-bringout-oca-server-auth-vault/doc/SECURITY.md
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
# Security
|
||||
|
||||
Access control and security definitions in vault.
|
||||
|
||||
## Access Control Lists (ACLs)
|
||||
|
||||
Model access permissions defined in:
|
||||
- **[ir.model.access.csv](../vault/security/ir.model.access.csv)**
|
||||
- 15 model access rules
|
||||
|
||||
## Record Rules
|
||||
|
||||
Row-level security rules defined in:
|
||||
- **[ir_rule.xml](../vault/security/ir_rule.xml)**
|
||||
|
||||
## Security Groups & Configuration
|
||||
|
||||
Security groups and permissions defined in:
|
||||
- **[ir_rule.xml](../vault/security/ir_rule.xml)**
|
||||
- **[vault_security.xml](../vault/security/vault_security.xml)**
|
||||
- 2 security groups defined
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph "Security Layers"
|
||||
A[Users] --> B[Groups]
|
||||
B --> C[Access Control Lists]
|
||||
C --> D[Models]
|
||||
B --> E[Record Rules]
|
||||
E --> F[Individual Records]
|
||||
end
|
||||
```
|
||||
|
||||
Security files overview:
|
||||
- **[ir.model.access.csv](../vault/security/ir.model.access.csv)**
|
||||
- Model access permissions (CRUD rights)
|
||||
- **[ir_rule.xml](../vault/security/ir_rule.xml)**
|
||||
- Security groups, categories, and XML-based rules
|
||||
- **[vault_security.xml](../vault/security/vault_security.xml)**
|
||||
- Security groups, categories, and XML-based rules
|
||||
|
||||
Notes
|
||||
- Access Control Lists define which groups can access which models
|
||||
- Record Rules provide row-level security (filter records by user/group)
|
||||
- Security groups organize users and define permission sets
|
||||
- All security is enforced at the ORM level by Odoo
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
# Troubleshooting
|
||||
|
||||
- Ensure Python and Odoo environment matches repo guidance.
|
||||
- Check database connectivity and logs if startup fails.
|
||||
- Validate that dependent addons listed in DEPENDENCIES.md are installed.
|
||||
7
odoo-bringout-oca-server-auth-vault/doc/USAGE.md
Normal file
7
odoo-bringout-oca-server-auth-vault/doc/USAGE.md
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
# Usage
|
||||
|
||||
Start Odoo including this addon (from repo root):
|
||||
|
||||
```bash
|
||||
python3 scripts/nix_odoo_web_server.py --db-name mydb --addon vault
|
||||
```
|
||||
3
odoo-bringout-oca-server-auth-vault/doc/WIZARDS.md
Normal file
3
odoo-bringout-oca-server-auth-vault/doc/WIZARDS.md
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# Wizards
|
||||
|
||||
This module does not include UI wizards.
|
||||
43
odoo-bringout-oca-server-auth-vault/pyproject.toml
Normal file
43
odoo-bringout-oca-server-auth-vault/pyproject.toml
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
[project]
|
||||
name = "odoo-bringout-oca-server-auth-vault"
|
||||
version = "16.0.0"
|
||||
description = "Vault - Password vault integration in Odoo"
|
||||
authors = [
|
||||
{ name = "Ernad Husremovic", email = "hernad@bring.out.ba" }
|
||||
]
|
||||
dependencies = [
|
||||
"odoo-bringout-oca-ocb-base_setup>=16.0.0",
|
||||
"odoo-bringout-oca-ocb-web>=16.0.0",
|
||||
"requests>=2.25.1"
|
||||
]
|
||||
readme = "README.md"
|
||||
requires-python = ">= 3.11"
|
||||
classifiers = [
|
||||
"Development Status :: 5 - Production/Stable",
|
||||
"Intended Audience :: Developers",
|
||||
"License :: OSI Approved :: GNU Lesser General Public License v3 (LGPLv3)",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
"Topic :: Office/Business",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
homepage = "https://github.com/bringout/0"
|
||||
repository = "https://github.com/bringout/0"
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.metadata]
|
||||
allow-direct-references = true
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["vault"]
|
||||
|
||||
[tool.rye]
|
||||
managed = true
|
||||
dev-dependencies = [
|
||||
"pytest>=8.4.1",
|
||||
]
|
||||
103
odoo-bringout-oca-server-auth-vault/vault/README.rst
Normal file
103
odoo-bringout-oca-server-auth-vault/vault/README.rst
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
=====
|
||||
Vault
|
||||
=====
|
||||
|
||||
..
|
||||
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
|
||||
!! This file is generated by oca-gen-addon-readme !!
|
||||
!! changes will be overwritten. !!
|
||||
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
|
||||
!! source digest: sha256:c0c446463d63752dc25080d52e0132ae87b0b43fd51edc7915ed1b919763b40e
|
||||
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
|
||||
|
||||
.. |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/16.0/vault
|
||||
: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-16-0/server-auth-16-0-vault
|
||||
: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=16.0
|
||||
:alt: Try me on Runboat
|
||||
|
||||
|badge1| |badge2| |badge3| |badge4| |badge5|
|
||||
|
||||
This module implements a vault for secrets and files using end-to-end-encryption. The encryption and decryption happens in the browser using a vault specific shared master key. The master keys are encrypted using asymmetrically. For this the user has to enter a second password on the first login or if he needs to access data in a vault. The asymmetric keys are stored for a certain time in the browser storage.
|
||||
|
||||
The server can never access the secrets with the information available. Only people registered in the vault can decrypt or encrypt values in a vault. The meta data isn't encrypted to be able to search/filter for entries more easily.
|
||||
|
||||
This modules requires a secure context for the browser to work properly and therefore HTTPS support is required.
|
||||
|
||||
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.
|
||||
|
||||
**Table of contents**
|
||||
|
||||
.. contents::
|
||||
:local:
|
||||
|
||||
Known issues / Roadmap
|
||||
======================
|
||||
|
||||
* Field and file history for restoration
|
||||
|
||||
* Import improvement
|
||||
|
||||
* Support challenge-response/FIDO2
|
||||
* Support for argon2 and kdbx v4
|
||||
|
||||
* When changing an entry from one vault to another existing vault, the values added on
|
||||
this entry cannot be accessed, so the field vault is going to be readonly when it
|
||||
is defined.
|
||||
|
||||
If you want to move entries between vaults you can use the export -> import option.
|
||||
|
||||
* HTTPS or localhost (secure browser context) is required for the client side encryption
|
||||
|
||||
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%0Aversion:%2016.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
|
||||
~~~~~~~
|
||||
|
||||
* initOS GmbH
|
||||
|
||||
Contributors
|
||||
~~~~~~~~~~~~
|
||||
|
||||
* Florian Kantelberg <florian.kantelberg@initos.com>
|
||||
* `Tecnativa <https://www.tecnativa.com>`_:
|
||||
|
||||
* Carlos Roca
|
||||
|
||||
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/16.0/vault>`_ project on GitHub.
|
||||
|
||||
You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.
|
||||
143
odoo-bringout-oca-server-auth-vault/vault/TECHNICAL.rst
Normal file
143
odoo-bringout-oca-server-auth-vault/vault/TECHNICAL.rst
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
::
|
||||
|
||||
┌───────┐ ┏━━━━━━━━━━━━━┓ ╔═══════════╗
|
||||
│ input │ ┃ unencrypted ┃ ║ encrypted ║
|
||||
└───────┘ ┗━━━━━━━━━━━━━┛ ╚═══════════╝
|
||||
|
||||
Vault
|
||||
=====
|
||||
|
||||
Each vault stores entries with enrypted fields and files in a tree like structure. The access is controlled per vault. Every added user can read the secrets of a vault. Otherwise the users can receive permission to share the vault with other users, to write secrets in the vault, or to delete entries of the vault. The databases stores the public and password protected private key of each user. The password used for the private key is derived from a password entered by the user and should be different than the password used for the login. Keep in mind that the meta information like field name or file names aren't encrypted.
|
||||
|
||||
Shared-key encryption
|
||||
=====================
|
||||
|
||||
To be able to securely share sensitive data between all users a shared-key encryption is used. All users share a common secret for each vault. This secret is encrypted by the public key of each user to grant access to the user by using the private key to restore the secret.
|
||||
|
||||
Encryption of master key
|
||||
------------------------
|
||||
|
||||
::
|
||||
|
||||
. ┏━━━━━━━━━━━━┓
|
||||
┃ Master key ┃
|
||||
┗━━━━━━━━━━━━┛
|
||||
┏━━━━━━━━━━━━━━━━━┓ ┃
|
||||
┃ User ┃ ▼
|
||||
┃ ┃ ┏━━━━━━━━━┓
|
||||
┃ ┏━━━━━━━━━━━━━┓ ┃ ┃ encrypt ┃ ╔════════════╗
|
||||
┃ ┃ Public key ┃━━━━▶┃ (RSA) ┃━━━━━▶║ Master key ║
|
||||
┃ ┗━━━━━━━━━━━━━┛ ┃ ┗━━━━━━━━━┛ ╚════════════╝
|
||||
┃ ╔═════════════╗ ┃
|
||||
┃ ║ Private key ║ ┃
|
||||
┃ ╚═════════════╝ ┃
|
||||
┗━━━━━━━━━━━━━━━━━┛
|
||||
|
||||
Decryption of master key
|
||||
------------------------
|
||||
|
||||
::
|
||||
|
||||
. ┌──────────┐ ┏━━━━━━━━━━┓
|
||||
│ Password │━━━━▶┃ derive ┃
|
||||
└──────────┘ ┃ (PBKDF2) ┃
|
||||
┗━━━━━━━━━━┛
|
||||
┃
|
||||
┏━━━━━━━━━━━━━━━━━┓ ▼ ╔════════════╗
|
||||
┃ User ┃ ┏━━━━━━━━━━┓ ║ Master key ║
|
||||
┃ ┃ ┃ Password ┃ ╚════════════╝
|
||||
┃ ┏━━━━━━━━━━━━━┓ ┃ ┗━━━━━━━━━━┛ ┃
|
||||
┃ ┃ Public key ┃ ┃ ┃ ▼
|
||||
┃ ┗━━━━━━━━━━━━━┛ ┃ ▼ ┏━━━━━━━━━┓
|
||||
┃ ╔═════════════╗ ┃ ┏━━━━━━━━┓ ┏━━━━━━━━━━━━━┓ ┃ decrypt ┃ ┏━━━━━━━━━━━━┓
|
||||
┃ ║ Private key ║━━━━━┃ unlock ┃━━▶┃ Private key ┃━━━▶┃ (RSA) ┃━━━━━▶┃ Master key ┃
|
||||
┃ ╚═════════════╝ ┃ ┗━━━━━━━━┛ ┗━━━━━━━━━━━━━┛ ┗━━━━━━━━━┛ ┗━━━━━━━━━━━━┛
|
||||
┗━━━━━━━━━━━━━━━━━┛
|
||||
|
||||
Symmetric encryption of the data
|
||||
================================
|
||||
|
||||
The symmetric cipher AES is used with the common master key to encrypt/decrypt the secrets of the vaults. The encryption parameter and encrypted data is stored in the database while everything else happens in the browser.
|
||||
|
||||
Encryption of data
|
||||
------------------
|
||||
|
||||
::
|
||||
|
||||
. ┏━━━━━━━━━━━━┓
|
||||
┃ Master key ┃
|
||||
┗━━━━━━━━━━━━┛
|
||||
┃ ┏━━━━━━━━━━━━━━━━━━┓
|
||||
▼ ┃ Database ┃
|
||||
┏━━━━━━━━━┓ ┃ ┃
|
||||
┏━━━━━━━━━━━━┓ ┃ encrypt ┃ ┃╔════════════════╗┃
|
||||
┃ Plain text ┃━━▶┃ (AES) ┃━━━▶║ Encrypted data ║┃
|
||||
┗━━━━━━━━━━━━┛ ┗━━━━━━━━━┛ ┃╚════════════════╝┃
|
||||
┃ ┃┏━━━━━━━━━━━━━━━━┓┃
|
||||
┗━━━━━━━━▶┃ Parameters ┃┃
|
||||
┃┗━━━━━━━━━━━━━━━━┛┃
|
||||
┗━━━━━━━━━━━━━━━━━━┛
|
||||
|
||||
Decryption of data
|
||||
------------------
|
||||
|
||||
::
|
||||
|
||||
. ┏━━━━━━━━━━━━┓
|
||||
┃ Master key ┃
|
||||
┗━━━━━━━━━━━━┛
|
||||
┏━━━━━━━━━━━━━━━━━━┓ ┃
|
||||
┃ Database ┃ ▼
|
||||
┃ ┃ ┏━━━━━━━━━┓
|
||||
┃╔════════════════╗┃ ┃ decrypt ┃ ┏━━━━━━━━━━━━┓
|
||||
┃║ Encrypted data ║━━━▶┃ (AES) ┃━━▶┃ Plain text ┃
|
||||
┃╚════════════════╝┃ ┗━━━━━━━━━┛ ┗━━━━━━━━━━━━┛
|
||||
┃┏━━━━━━━━━━━━━━━━┓┃ ▲
|
||||
┃┃ Parameters ┃━━━━━━━━┛
|
||||
┃┗━━━━━━━━━━━━━━━━┛┃
|
||||
┗━━━━━━━━━━━━━━━━━━┛
|
||||
|
||||
Inbox
|
||||
=====
|
||||
|
||||
This allows an user to receive encrypted secrets by external or internal Odoo users. External users have to use either the owner specific inbox link from his preferences or the link of an already created inbox. The value is symmetrically encrypted. The key for the encryption is wrapped with the public key of the user of the inbox to grant the user the access to the key. Internal users can directly send a secret from a vault entry to another user who has enabled this feature. If a direct link is used the access counter and expiration time can block an overwrite.
|
||||
|
||||
Encryption of inbox
|
||||
-------------------
|
||||
|
||||
::
|
||||
|
||||
. ┏━━━━━━━━━━━━┓
|
||||
┃ Plain data ┃
|
||||
┗━━━━━━━━━━━━┛
|
||||
┏━━━━━━━━━━━━━━━━━┓ ┃
|
||||
┃ User ┃ ▼
|
||||
┃ ┃ ┏━━━━━━━━━┓
|
||||
┃ ┏━━━━━━━━━━━━━┓ ┃ ┃ encrypt ┃ ╔════════════════╗
|
||||
┃ ┃ Public key ┃━━━━▶┃ (RSA) ┃━━━━━▶║ Encrypted data ║
|
||||
┃ ┗━━━━━━━━━━━━━┛ ┃ ┗━━━━━━━━━┛ ╚════════════════╝
|
||||
┃ ╔═════════════╗ ┃
|
||||
┃ ║ Private key ║ ┃
|
||||
┃ ╚═════════════╝ ┃
|
||||
┗━━━━━━━━━━━━━━━━━┛
|
||||
|
||||
Decryption of inbox
|
||||
-------------------
|
||||
|
||||
::
|
||||
|
||||
. ┌──────────┐ ┏━━━━━━━━━━┓
|
||||
│ Password │━━━━▶┃ derive ┃
|
||||
└──────────┘ ┃ (PBKDF2) ┃
|
||||
┗━━━━━━━━━━┛
|
||||
┃
|
||||
┏━━━━━━━━━━━━━━━━━┓ ▼ ╔════════════════╗
|
||||
┃ User ┃ ┏━━━━━━━━━━┓ ║ Encrypted data ║
|
||||
┃ ┃ ┃ Password ┃ ╚════════════════╝
|
||||
┃ ┏━━━━━━━━━━━━━┓ ┃ ┗━━━━━━━━━━┛ ┃
|
||||
┃ ┃ Public key ┃ ┃ ┃ ▼
|
||||
┃ ┗━━━━━━━━━━━━━┛ ┃ ▼ ┏━━━━━━━━━┓
|
||||
┃ ╔═════════════╗ ┃ ┏━━━━━━━━┓ ┏━━━━━━━━━━━━━┓ ┃ decrypt ┃ ┏━━━━━━━━━━━━┓
|
||||
┃ ║ Private key ║━━━━━┃ unlock ┃━━▶┃ Private key ┃━━━▶┃ (RSA) ┃━━━━━▶┃ Plain data ┃
|
||||
┃ ╚═════════════╝ ┃ ┗━━━━━━━━┛ ┗━━━━━━━━━━━━━┛ ┗━━━━━━━━━┛ ┗━━━━━━━━━━━━┛
|
||||
┗━━━━━━━━━━━━━━━━━┛
|
||||
4
odoo-bringout-oca-server-auth-vault/vault/__init__.py
Normal file
4
odoo-bringout-oca-server-auth-vault/vault/__init__.py
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
# © 2021 Florian Kantelberg - initOS GmbH
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
||||
|
||||
from . import controllers, models, wizards
|
||||
50
odoo-bringout-oca-server-auth-vault/vault/__manifest__.py
Normal file
50
odoo-bringout-oca-server-auth-vault/vault/__manifest__.py
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
# © 2021 Florian Kantelberg - initOS GmbH
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
||||
|
||||
{
|
||||
"name": "Vault",
|
||||
"summary": "Password vault integration in Odoo",
|
||||
"license": "AGPL-3",
|
||||
"version": "16.0.1.0.3",
|
||||
"website": "https://github.com/OCA/server-auth",
|
||||
"application": True,
|
||||
"author": "initOS GmbH, Odoo Community Association (OCA)",
|
||||
"category": "Vault",
|
||||
"depends": ["base_setup", "web"],
|
||||
"data": [
|
||||
"security/ir.model.access.csv",
|
||||
"security/ir_rule.xml",
|
||||
"security/vault_security.xml",
|
||||
"views/res_config_settings_views.xml",
|
||||
"views/res_users_views.xml",
|
||||
"views/vault_entry_views.xml",
|
||||
"views/vault_field_views.xml",
|
||||
"views/vault_file_views.xml",
|
||||
"views/vault_log_views.xml",
|
||||
"views/vault_inbox_views.xml",
|
||||
"views/vault_right_views.xml",
|
||||
"views/vault_views.xml",
|
||||
"views/menuitems.xml",
|
||||
"views/templates.xml",
|
||||
"wizards/vault_export_wizard.xml",
|
||||
"wizards/vault_import_wizard.xml",
|
||||
"wizards/vault_send_wizard.xml",
|
||||
"wizards/vault_store_wizard.xml",
|
||||
],
|
||||
"assets": {
|
||||
"vault.assets_frontend": [
|
||||
"vault/static/src/common/*.js",
|
||||
"vault/static/src/frontend/*.js",
|
||||
],
|
||||
"web.assets_backend": [
|
||||
"vault/static/lib/**/*.min.js",
|
||||
"vault/static/src/**/*.xml",
|
||||
"vault/static/src/common/*.js",
|
||||
"vault/static/src/backend/*.scss",
|
||||
"vault/static/src/backend/**/*.js",
|
||||
],
|
||||
"web.tests_assets": [
|
||||
"vault/static/tests/**/*.js",
|
||||
],
|
||||
},
|
||||
}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
# © 2021 Florian Kantelberg - initOS GmbH
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
||||
|
||||
from . import main
|
||||
152
odoo-bringout-oca-server-auth-vault/vault/controllers/main.py
Normal file
152
odoo-bringout-oca-server-auth-vault/vault/controllers/main.py
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
# © 2021 Florian Kantelberg - initOS GmbH
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
||||
|
||||
import logging
|
||||
|
||||
from odoo import _, http
|
||||
from odoo.exceptions import AccessDenied
|
||||
from odoo.http import request
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Controller(http.Controller):
|
||||
@http.route("/vault/inbox/<string:token>", type="http", auth="public")
|
||||
def vault_inbox(self, token):
|
||||
ctx = {"disable_footer": True, "token": token}
|
||||
# Find the right token
|
||||
inbox = request.env["vault.inbox"].sudo().find_inbox(token)
|
||||
user = request.env["res.users"].sudo().find_user_of_inbox(token)
|
||||
if len(inbox) == 1 and inbox.accesses > 0:
|
||||
ctx.update({"name": inbox.name, "public": inbox.user_id.active_key.public})
|
||||
elif len(inbox) == 0 and len(user) == 1:
|
||||
ctx["public"] = user.active_key.public
|
||||
|
||||
# A valid token would mean we found a public key
|
||||
if not ctx.get("public"):
|
||||
ctx["error"] = _("Invalid token")
|
||||
return request.render("vault.inbox", ctx)
|
||||
|
||||
# Just render if GET method
|
||||
if request.httprequest.method != "POST":
|
||||
return request.render("vault.inbox", ctx)
|
||||
|
||||
# Check the param
|
||||
name = request.params.get("name")
|
||||
secret = request.params.get("encrypted")
|
||||
secret_file = request.params.get("encrypted_file")
|
||||
filename = request.params.get("filename")
|
||||
iv = request.params.get("iv")
|
||||
key = request.params.get("key")
|
||||
if not name:
|
||||
ctx["error"] = _("Please specify a name")
|
||||
return request.render("vault.inbox", ctx)
|
||||
|
||||
if not secret and not secret_file:
|
||||
ctx["error"] = _("No secret found")
|
||||
return request.render("vault.inbox", ctx)
|
||||
|
||||
if secret_file and not filename:
|
||||
ctx["error"] = _("Missing filename")
|
||||
return request.render("vault.inbox", ctx)
|
||||
|
||||
if not iv or not key:
|
||||
ctx["error"] = _("Something went wrong with the encryption")
|
||||
return request.render("vault.inbox", ctx)
|
||||
|
||||
try:
|
||||
inbox.store_in_inbox(
|
||||
name,
|
||||
secret,
|
||||
secret_file,
|
||||
iv,
|
||||
key,
|
||||
user,
|
||||
filename,
|
||||
ip=request.httprequest.remote_addr,
|
||||
)
|
||||
except Exception as e:
|
||||
_logger.exception(e)
|
||||
ctx["error"] = _(
|
||||
"An error occured. Please contact the user or administrator"
|
||||
)
|
||||
return request.render("vault.inbox", ctx)
|
||||
|
||||
ctx["message"] = _("Successfully stored")
|
||||
return request.render("vault.inbox", ctx)
|
||||
|
||||
@http.route("/vault/public", type="json")
|
||||
def vault_public(self, user_id):
|
||||
"""Get the public key of a specific user"""
|
||||
user = request.env["res.users"].sudo().browse(user_id).exists()
|
||||
if not user or not user.keys:
|
||||
return {}
|
||||
|
||||
return {"public_key": user.active_key.public}
|
||||
|
||||
@http.route("/vault/inbox/get", auth="user", type="json")
|
||||
def vault_get_inbox(self):
|
||||
inboxes = request.env.user.inbox_ids
|
||||
return {inbox.token: inbox.key for inbox in inboxes}
|
||||
|
||||
@http.route("/vault/inbox/store", auth="user", type="json")
|
||||
def vault_store_inbox(self, keys):
|
||||
if not isinstance(keys, dict):
|
||||
return
|
||||
|
||||
for inbox in request.env.user.inbox_ids:
|
||||
key = keys.get(inbox.token)
|
||||
|
||||
if isinstance(key, str):
|
||||
inbox.key = key
|
||||
|
||||
@http.route("/vault/keys/store", auth="user", type="json")
|
||||
def vault_store_keys(self, **kwargs):
|
||||
"""Store the key pair for the current user"""
|
||||
return request.env["res.users.key"].store(**kwargs)
|
||||
|
||||
@http.route("/vault/keys/get", auth="user", type="json")
|
||||
def vault_get_keys(self):
|
||||
"""Get the currently active key pair"""
|
||||
return request.env.user.get_vault_keys()
|
||||
|
||||
@http.route("/vault/rights/get", auth="user", type="json")
|
||||
def vault_get_right_keys(self):
|
||||
"""Get the master keys from the vault.right records"""
|
||||
rights = request.env.user.vault_right_ids
|
||||
return {right.vault_id.uuid: right.key for right in rights}
|
||||
|
||||
@http.route("/vault/rights/store", auth="user", type="json")
|
||||
def vault_store_right_keys(self, keys):
|
||||
"""Store the master keys to the specific vault.right records"""
|
||||
if not isinstance(keys, dict):
|
||||
return
|
||||
|
||||
for right in request.env.user.vault_right_ids:
|
||||
master_key = keys.get(right.vault_id.uuid)
|
||||
|
||||
if isinstance(master_key, str):
|
||||
right.sudo().key = master_key
|
||||
|
||||
@http.route("/vault/replace", auth="user", type="json")
|
||||
def vault_replace(self, data):
|
||||
"""Replace the master keys and values within a single transaction"""
|
||||
if not isinstance(data, list):
|
||||
return
|
||||
|
||||
vault = request.env["vault"].with_context(vault_skip_log=True)
|
||||
for changes in data:
|
||||
record = vault.env[changes["model"]].browse(changes["id"])
|
||||
if not record.vault_id.allowed_write:
|
||||
raise AccessDenied()
|
||||
|
||||
vault |= record.vault_id
|
||||
if record._name in ("vault.field", "vault.file"):
|
||||
record.write({k: v for k, v in changes.items() if k in ["iv", "value"]})
|
||||
elif record._name == "vault.right":
|
||||
record.write({k: v for k, v in changes.items() if k in ["key"]})
|
||||
|
||||
for v in vault:
|
||||
v._log_entry("Replaced the keys", "info")
|
||||
|
||||
vault.sudo().write({"reencrypt_required": False})
|
||||
1478
odoo-bringout-oca-server-auth-vault/vault/i18n/bs.po
Normal file
1478
odoo-bringout-oca-server-auth-vault/vault/i18n/bs.po
Normal file
File diff suppressed because it is too large
Load diff
1507
odoo-bringout-oca-server-auth-vault/vault/i18n/es.po
Normal file
1507
odoo-bringout-oca-server-auth-vault/vault/i18n/es.po
Normal file
File diff suppressed because it is too large
Load diff
1503
odoo-bringout-oca-server-auth-vault/vault/i18n/it.po
Normal file
1503
odoo-bringout-oca-server-auth-vault/vault/i18n/it.po
Normal file
File diff suppressed because it is too large
Load diff
1477
odoo-bringout-oca-server-auth-vault/vault/i18n/nl.po
Normal file
1477
odoo-bringout-oca-server-auth-vault/vault/i18n/nl.po
Normal file
File diff suppressed because it is too large
Load diff
1478
odoo-bringout-oca-server-auth-vault/vault/i18n/vault.pot
Normal file
1478
odoo-bringout-oca-server-auth-vault/vault/i18n/vault.pot
Normal file
File diff suppressed because it is too large
Load diff
19
odoo-bringout-oca-server-auth-vault/vault/models/__init__.py
Normal file
19
odoo-bringout-oca-server-auth-vault/vault/models/__init__.py
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
# © 2021 Florian Kantelberg - initOS GmbH
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
||||
|
||||
from . import (
|
||||
abstract_vault,
|
||||
abstract_vault_field,
|
||||
res_config_settings,
|
||||
res_users,
|
||||
res_users_key,
|
||||
vault,
|
||||
vault_entry,
|
||||
vault_field,
|
||||
vault_file,
|
||||
vault_inbox,
|
||||
vault_inbox_log,
|
||||
vault_log,
|
||||
vault_right,
|
||||
vault_tag,
|
||||
)
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
# © 2021 Florian Kantelberg - initOS GmbH
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
||||
|
||||
import logging
|
||||
|
||||
from odoo import _, api, models
|
||||
from odoo.exceptions import AccessError
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AbstractVault(models.AbstractModel):
|
||||
"""Models must have the following fields:
|
||||
`perm_user`: The permissions are computed for this user
|
||||
`allowed_read`: The current user can read from the vault
|
||||
`allowed_create`: The current user can read from the vault
|
||||
`allowed_write`: The current user has write access to the vault
|
||||
`allowed_share`: The current user can share the vault with other users
|
||||
`allowed_delete`: The current user can delete the vault or entries of it
|
||||
"""
|
||||
|
||||
_name = "vault.abstract"
|
||||
_description = _("Abstract model to implement general access rights")
|
||||
|
||||
@api.model
|
||||
def raise_access_error(self):
|
||||
raise AccessError(
|
||||
_(
|
||||
"The requested operation can not be completed due to security "
|
||||
"restrictions."
|
||||
)
|
||||
)
|
||||
|
||||
def check_access_rule(self, operation):
|
||||
super().check_access_rule(operation)
|
||||
|
||||
if self.env.su:
|
||||
return
|
||||
|
||||
# We have to recompute if the user of the environment changed
|
||||
if self.env.user != self.mapped("perm_user"):
|
||||
vault = self if self._name == "vault" else self.mapped("vault_id")
|
||||
vault._compute_access()
|
||||
|
||||
# Shortcut for vault.right because only the share right is required
|
||||
if self._name == "vault.right":
|
||||
if not self.filtered("allowed_share"):
|
||||
self.raise_access_error()
|
||||
return
|
||||
|
||||
# Check the operation and matching permissions
|
||||
if operation == "read" and not self.filtered("allowed_read"):
|
||||
self.raise_access_error()
|
||||
|
||||
if operation == "create" and not self.filtered("allowed_create"):
|
||||
self.raise_access_error()
|
||||
|
||||
if operation == "write" and not self.filtered("allowed_write"):
|
||||
self.raise_access_error()
|
||||
|
||||
if operation == "unlink" and not self.filtered("allowed_delete"):
|
||||
self.raise_access_error()
|
||||
|
||||
def _log_entry(self, msg, state):
|
||||
raise NotImplementedError()
|
||||
|
||||
def log_entry(self, msg):
|
||||
return self._log_entry(msg, None)
|
||||
|
||||
def log_info(self, msg):
|
||||
return self._log_entry(msg, "info")
|
||||
|
||||
def log_warn(self, msg):
|
||||
return self._log_entry(msg, "warn")
|
||||
|
||||
def log_error(self, msg):
|
||||
return self._log_entry(msg, "error")
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
# © 2021 Florian Kantelberg - initOS GmbH
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
||||
|
||||
import logging
|
||||
|
||||
from odoo import _, api, fields, models
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AbstractVaultField(models.AbstractModel):
|
||||
_name = "vault.abstract.field"
|
||||
_description = _("Abstract model to implement basic fields for encryption")
|
||||
|
||||
entry_id = fields.Many2one("vault.entry", ondelete="cascade", required=True)
|
||||
entry_name = fields.Char(related="entry_id.complete_name")
|
||||
vault_id = fields.Many2one(related="entry_id.vault_id")
|
||||
master_key = fields.Char(compute="_compute_master_key", store=False)
|
||||
|
||||
perm_user = fields.Many2one(related="vault_id.perm_user", store=False)
|
||||
allowed_read = fields.Boolean(related="vault_id.allowed_read", store=False)
|
||||
allowed_create = fields.Boolean(related="vault_id.allowed_create", store=False)
|
||||
allowed_write = fields.Boolean(related="vault_id.allowed_write", store=False)
|
||||
allowed_share = fields.Boolean(related="vault_id.allowed_share", store=False)
|
||||
allowed_delete = fields.Boolean(related="vault_id.allowed_delete", store=False)
|
||||
|
||||
name = fields.Char(required=True)
|
||||
iv = fields.Char()
|
||||
|
||||
@api.depends("entry_id.vault_id.master_key")
|
||||
def _compute_master_key(self):
|
||||
for rec in self:
|
||||
rec.master_key = rec.vault_id.master_key
|
||||
|
||||
def log_change(self, action):
|
||||
if self.env.context.get("vault_skip_log"):
|
||||
return
|
||||
|
||||
for rec in self:
|
||||
rec.entry_id.log_info(
|
||||
f"{action} value {rec.name} of {rec.entry_id.complete_name} "
|
||||
f"by {self.env.user.display_name}"
|
||||
)
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
res = super().create(vals_list)
|
||||
res.log_change("Created")
|
||||
return res
|
||||
|
||||
def unlink(self):
|
||||
self.log_change("Deleted")
|
||||
return super().unlink()
|
||||
|
||||
def write(self, values):
|
||||
self.log_change("Changed")
|
||||
return super().write(values)
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
# © 2021 Florian Kantelberg - initOS GmbH
|
||||
# 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"
|
||||
|
||||
module_vault_share = fields.Boolean()
|
||||
group_vault_export = fields.Boolean(
|
||||
"Export Vaults", implied_group="vault.group_vault_export"
|
||||
)
|
||||
group_vault_import = fields.Boolean(
|
||||
"Import Vaults", implied_group="vault.group_vault_import"
|
||||
)
|
||||
|
|
@ -0,0 +1,78 @@
|
|||
# © 2021 Florian Kantelberg - initOS GmbH
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
||||
|
||||
import logging
|
||||
from uuid import uuid4
|
||||
|
||||
from odoo import api, fields, models
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ResUsers(models.Model):
|
||||
_inherit = "res.users"
|
||||
|
||||
active_key = fields.Many2one(
|
||||
"res.users.key",
|
||||
compute="_compute_active_key",
|
||||
store=False,
|
||||
)
|
||||
keys = fields.One2many("res.users.key", "user_id", readonly=True)
|
||||
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", "keys.current")
|
||||
def _compute_active_key(self):
|
||||
for rec in self:
|
||||
keys = rec.sudo().keys.filtered("current")
|
||||
rec.active_key = keys[0] if keys else None
|
||||
|
||||
@api.depends("inbox_token")
|
||||
def _compute_inbox_link(self):
|
||||
base_url = self.env["ir.config_parameter"].sudo().get_param("web.base.url")
|
||||
for rec in self:
|
||||
rec.inbox_link = f"{base_url}/vault/inbox/{rec.inbox_token}"
|
||||
|
||||
@api.model
|
||||
def action_get_vault(self):
|
||||
action = self.sudo().env.ref("vault.action_res_users_keys")
|
||||
result = action.read()[0]
|
||||
result["res_id"] = self.env.uid
|
||||
return result
|
||||
|
||||
def action_new_inbox_token(self):
|
||||
self.ensure_one()
|
||||
self.sudo().inbox_token = uuid4()
|
||||
return self.action_get_vault()
|
||||
|
||||
def action_invalidate_key(self):
|
||||
"""Disable the current key and remove all accesses to the vaults"""
|
||||
self.ensure_one()
|
||||
self.keys.write({"current": False})
|
||||
self.vault_right_ids.sudo().unlink()
|
||||
self.inbox_ids.unlink()
|
||||
self.env["vault"].search([])._compute_access()
|
||||
return self.action_get_vault()
|
||||
|
||||
@api.model
|
||||
def find_user_of_inbox(self, token):
|
||||
return self.search([("inbox_token", "=", token), ("inbox_enabled", "=", True)])
|
||||
|
||||
def get_vault_keys(self):
|
||||
self.ensure_one()
|
||||
|
||||
if not self.active_key:
|
||||
return {}
|
||||
|
||||
return {
|
||||
"iterations": self.active_key.iterations,
|
||||
"iv": self.active_key.iv,
|
||||
"private": self.active_key.private,
|
||||
"public": self.active_key.public,
|
||||
"salt": self.active_key.salt,
|
||||
"uuid": self.active_key.uuid,
|
||||
"version": self.active_key.version,
|
||||
}
|
||||
|
|
@ -0,0 +1,82 @@
|
|||
# © 2021 Florian Kantelberg - initOS GmbH
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
||||
|
||||
import logging
|
||||
import re
|
||||
from hashlib import sha256
|
||||
from uuid import uuid4
|
||||
|
||||
from odoo import _, api, fields, models
|
||||
from odoo.exceptions import ValidationError
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ResUsersKey(models.Model):
|
||||
_name = "res.users.key"
|
||||
_description = _("User data of a vault")
|
||||
_rec_name = "fingerprint"
|
||||
_order = "create_date DESC"
|
||||
|
||||
user_id = fields.Many2one("res.users", required=True)
|
||||
uuid = fields.Char(default=lambda self: uuid4(), required=True, readonly=True)
|
||||
current = fields.Boolean(default=True, readonly=True)
|
||||
fingerprint = fields.Char(compute="_compute_fingerprint", store=True)
|
||||
public = fields.Char(required=True, readonly=True)
|
||||
salt = fields.Char(required=True, readonly=True)
|
||||
iv = fields.Char(required=True, readonly=True)
|
||||
iterations = fields.Integer(required=True, readonly=True)
|
||||
version = fields.Integer(readonly=True)
|
||||
# Encrypted with master password of user
|
||||
private = fields.Char(required=True, readonly=True)
|
||||
|
||||
@api.depends("public")
|
||||
def _compute_fingerprint(self):
|
||||
for rec in self:
|
||||
if rec.public:
|
||||
hashed = sha256(rec.public.encode()).hexdigest()
|
||||
rec.fingerprint = ":".join(re.findall(r".{2}", hashed))
|
||||
else:
|
||||
rec.fingerprint = False
|
||||
|
||||
def _prepare_values(self, iterations, iv, private, public, salt, version):
|
||||
return {
|
||||
"iterations": iterations,
|
||||
"iv": iv,
|
||||
"private": private,
|
||||
"public": public,
|
||||
"salt": salt,
|
||||
"user_id": self.env.uid,
|
||||
"current": True,
|
||||
"version": version,
|
||||
}
|
||||
|
||||
def store(self, iterations, iv, private, public, salt, version):
|
||||
if not all(isinstance(x, str) and x for x in [public, private, iv, salt]):
|
||||
raise ValidationError(_("Invalid parameter"))
|
||||
|
||||
if not isinstance(iterations, int) or iterations < 4000:
|
||||
raise ValidationError(_("Invalid parameter"))
|
||||
|
||||
if not isinstance(version, int):
|
||||
raise ValidationError(_("Invalid parameter"))
|
||||
|
||||
domain = [
|
||||
("user_id", "=", self.env.uid),
|
||||
("private", "=", private),
|
||||
]
|
||||
key = self.search(domain)
|
||||
if not key:
|
||||
# Disable all current keys
|
||||
self.env.user.keys.write({"current": False})
|
||||
|
||||
rec = self.create(
|
||||
self._prepare_values(iterations, iv, private, public, salt, version)
|
||||
)
|
||||
return rec.uuid
|
||||
|
||||
return False
|
||||
|
||||
def extract_public_key(self, user):
|
||||
user = self.sudo().search([("user_id", "=", user), ("current", "=", True)])
|
||||
return user.public or None
|
||||
165
odoo-bringout-oca-server-auth-vault/vault/models/vault.py
Normal file
165
odoo-bringout-oca-server-auth-vault/vault/models/vault.py
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
# © 2021-2024 Florian Kantelberg - initOS GmbH
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
||||
|
||||
import logging
|
||||
from uuid import uuid4
|
||||
|
||||
from odoo import _, api, fields, models
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Vault(models.Model):
|
||||
_name = "vault"
|
||||
_description = _("Vault")
|
||||
_inherit = ["vault.abstract"]
|
||||
_order = "name"
|
||||
|
||||
user_id = fields.Many2one(
|
||||
"res.users",
|
||||
"Owner",
|
||||
readonly=True,
|
||||
default=lambda self: self.env.user,
|
||||
required=True,
|
||||
)
|
||||
right_ids = fields.One2many(
|
||||
"vault.right",
|
||||
"vault_id",
|
||||
"Rights",
|
||||
default=lambda self: self._get_default_rights(),
|
||||
)
|
||||
entry_ids = fields.One2many("vault.entry", "vault_id", "Entries")
|
||||
field_ids = fields.One2many("vault.field", "vault_id", "Fields")
|
||||
file_ids = fields.One2many("vault.file", "vault_id", "Files")
|
||||
log_ids = fields.One2many("vault.log", "vault_id", "Log", readonly=True)
|
||||
reencrypt_required = fields.Boolean(default=False)
|
||||
|
||||
# Access control
|
||||
perm_user = fields.Many2one("res.users", compute="_compute_access", store=False)
|
||||
allowed_read = fields.Boolean(compute="_compute_access", store=False)
|
||||
allowed_create = fields.Boolean(compute="_compute_access", store=False)
|
||||
allowed_share = fields.Boolean(compute="_compute_access", store=False)
|
||||
allowed_write = fields.Boolean(compute="_compute_access", store=False)
|
||||
allowed_delete = fields.Boolean(compute="_compute_access", store=False)
|
||||
|
||||
master_key = fields.Char(
|
||||
compute="_compute_master_key",
|
||||
inverse="_inverse_master_key",
|
||||
store=False,
|
||||
)
|
||||
|
||||
uuid = fields.Char(default=lambda self: uuid4(), required=True, readonly=True)
|
||||
name = fields.Char(required=True)
|
||||
note = fields.Text()
|
||||
|
||||
_sql_constraints = [
|
||||
("uuid_uniq", "UNIQUE(uuid)", _("The UUID must be unique.")),
|
||||
]
|
||||
|
||||
@api.depends("right_ids.user_id")
|
||||
def _compute_access(self):
|
||||
user = self.env.user
|
||||
for rec in self.sudo():
|
||||
rec.perm_user = user.id
|
||||
|
||||
if user == rec.user_id:
|
||||
rec.write(
|
||||
{
|
||||
"allowed_create": True,
|
||||
"allowed_share": True,
|
||||
"allowed_write": True,
|
||||
"allowed_delete": True,
|
||||
"allowed_read": True,
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
||||
rights = rec.right_ids
|
||||
rec.allowed_read = user in rights.mapped("user_id")
|
||||
rec.allowed_create = user in rights.filtered("perm_create").mapped(
|
||||
"user_id"
|
||||
)
|
||||
rec.allowed_share = user in rights.filtered("perm_share").mapped("user_id")
|
||||
rec.allowed_write = user in rights.filtered("perm_write").mapped("user_id")
|
||||
rec.allowed_delete = user in rights.filtered("perm_delete").mapped(
|
||||
"user_id"
|
||||
)
|
||||
|
||||
@api.depends("right_ids.key")
|
||||
def _compute_master_key(self):
|
||||
domain = [("user_id", "=", self.env.uid)]
|
||||
for rec in self:
|
||||
rights = rec.right_ids.filtered_domain(domain)
|
||||
rec.master_key = rights[0].key if rights else False
|
||||
|
||||
def _inverse_master_key(self):
|
||||
domain = [("user_id", "=", self.env.uid)]
|
||||
for rec in self:
|
||||
rights = rec.right_ids.filtered_domain(domain)
|
||||
if rights and not rights.key:
|
||||
rights.key = rec.master_key
|
||||
|
||||
def _get_default_rights(self):
|
||||
return [
|
||||
(
|
||||
0,
|
||||
0,
|
||||
{
|
||||
"user_id": self.env.uid,
|
||||
"perm_create": True,
|
||||
"perm_write": True,
|
||||
"perm_delete": True,
|
||||
"perm_share": True,
|
||||
},
|
||||
)
|
||||
]
|
||||
|
||||
def _log_entry(self, msg, state):
|
||||
self.ensure_one()
|
||||
return (
|
||||
self.env["vault.log"]
|
||||
.sudo()
|
||||
.create(
|
||||
{
|
||||
"vault_id": self.id,
|
||||
"user_id": self.env.uid,
|
||||
"message": msg,
|
||||
"state": state,
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
def share_public_keys(self):
|
||||
self.ensure_one()
|
||||
result = []
|
||||
for right in self.right_ids:
|
||||
result.append({"user": right.user_id.id, "public": right.public_key})
|
||||
return result
|
||||
|
||||
def action_open_import_wizard(self):
|
||||
self.ensure_one()
|
||||
wizard = self.env.ref("vault.view_vault_import_wizard")
|
||||
return {
|
||||
"name": _("Import from file"),
|
||||
"type": "ir.actions.act_window",
|
||||
"view_mode": "form",
|
||||
"res_model": "vault.import.wizard",
|
||||
"views": [(wizard.id, "form")],
|
||||
"view_id": wizard.id,
|
||||
"target": "new",
|
||||
"context": {"default_vault_id": self.id},
|
||||
}
|
||||
|
||||
def action_open_export_wizard(self):
|
||||
self.ensure_one()
|
||||
wizard = self.env.ref("vault.view_vault_export_wizard")
|
||||
return {
|
||||
"name": _("Export to file"),
|
||||
"type": "ir.actions.act_window",
|
||||
"view_mode": "form",
|
||||
"res_model": "vault.export.wizard",
|
||||
"views": [(wizard.id, "form")],
|
||||
"view_id": wizard.id,
|
||||
"target": "new",
|
||||
"context": {"default_vault_id": self.id},
|
||||
}
|
||||
215
odoo-bringout-oca-server-auth-vault/vault/models/vault_entry.py
Normal file
215
odoo-bringout-oca-server-auth-vault/vault/models/vault_entry.py
Normal file
|
|
@ -0,0 +1,215 @@
|
|||
# © 2021 Florian Kantelberg - initOS GmbH
|
||||
# Copyright 2022 Tecnativa - Víctor Martínez
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from uuid import uuid4
|
||||
|
||||
from odoo import _, api, fields, models
|
||||
from odoo.exceptions import ValidationError
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class VaultEntry(models.Model):
|
||||
_name = "vault.entry"
|
||||
_description = _("Entry inside a vault")
|
||||
_inherit = ["vault.abstract"]
|
||||
_order = "complete_name"
|
||||
_rec_name = "complete_name"
|
||||
|
||||
parent_id = fields.Many2one(
|
||||
"vault.entry",
|
||||
"Parent",
|
||||
ondelete="cascade",
|
||||
domain="[('vault_id', '=', vault_id)]",
|
||||
)
|
||||
child_ids = fields.One2many("vault.entry", "parent_id", "Child")
|
||||
|
||||
vault_id = fields.Many2one("vault", "Vault", ondelete="cascade", required=True)
|
||||
user_id = fields.Many2one(related="vault_id.user_id")
|
||||
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)
|
||||
|
||||
perm_user = fields.Many2one(related="vault_id.perm_user", store=False)
|
||||
allowed_read = fields.Boolean(related="vault_id.allowed_read", store=False)
|
||||
allowed_create = fields.Boolean(related="vault_id.allowed_create", store=False)
|
||||
allowed_share = fields.Boolean(related="vault_id.allowed_share", store=False)
|
||||
allowed_write = fields.Boolean(related="vault_id.allowed_write", store=False)
|
||||
allowed_delete = fields.Boolean(related="vault_id.allowed_delete", store=False)
|
||||
|
||||
complete_name = fields.Char(
|
||||
compute="_compute_complete_name",
|
||||
store=True,
|
||||
readonly=True,
|
||||
recursive=True,
|
||||
)
|
||||
uuid = fields.Char(default=lambda self: uuid4(), required=True, copy=False)
|
||||
name = fields.Char(required=True)
|
||||
url = fields.Char()
|
||||
note = fields.Text()
|
||||
tags = fields.Many2many("vault.tag")
|
||||
expire_date = fields.Datetime("Expires on", default=False)
|
||||
expired = fields.Boolean(
|
||||
compute="_compute_expired",
|
||||
search="_search_expired",
|
||||
store=False,
|
||||
)
|
||||
|
||||
_sql_constraints = [
|
||||
("vault_uuid_uniq", "UNIQUE(vault_id, uuid)", _("The UUID must be unique.")),
|
||||
]
|
||||
|
||||
@api.constrains("parent_id")
|
||||
def _check_parent_id(self):
|
||||
if not self._check_recursion():
|
||||
raise ValidationError(_("You can not create recursive entries."))
|
||||
|
||||
@api.depends("name", "parent_id.complete_name")
|
||||
def _compute_complete_name(self):
|
||||
for rec in self:
|
||||
if rec.parent_id:
|
||||
rec.complete_name = f"{rec.parent_id.complete_name} / {rec.name}"
|
||||
else:
|
||||
rec.complete_name = rec.name
|
||||
|
||||
@api.model
|
||||
def search_panel_select_range(self, field_name, **kwargs):
|
||||
"""We add the following contexts related to searchpanel:
|
||||
- entry_short_name: Show just the name instead of full path.
|
||||
- from_search_panel: It will be used to overwrite domain.
|
||||
Remove the limit of records (default is 200).
|
||||
"""
|
||||
return super(
|
||||
VaultEntry,
|
||||
self.with_context(from_search_panel=True, entry_short_name=True),
|
||||
).search_panel_select_range(field_name, **kwargs)
|
||||
|
||||
def search_read(self, domain=None, fields=None, offset=0, limit=None, order=None):
|
||||
"""Changes related to searchpanel:
|
||||
- Add a domain to only show records with children.
|
||||
"""
|
||||
domain = domain if domain else []
|
||||
if self.env.context.get("from_search_panel"):
|
||||
domain += [("child_ids", "!=", False)]
|
||||
return super().search_read(
|
||||
domain=domain, fields=fields, offset=offset, limit=limit, order=order
|
||||
)
|
||||
|
||||
def copy_data(self, default=None):
|
||||
self.ensure_one()
|
||||
|
||||
if default is None:
|
||||
default = {}
|
||||
|
||||
if "name" not in default:
|
||||
default["name"] = _("%s (copy)", self.name)
|
||||
|
||||
if "field_ids" not in default:
|
||||
default["field_ids"] = [
|
||||
(0, 0, field.copy_data()[0]) for field in self.field_ids
|
||||
]
|
||||
if "file_ids" not in default:
|
||||
default["file_ids"] = [
|
||||
(0, 0, field.copy_data()[0]) for field in self.file_ids
|
||||
]
|
||||
return super().copy_data(default)
|
||||
|
||||
@api.depends("name", "complete_name")
|
||||
def _compute_display_name(self):
|
||||
if not self.env.context.get("entry_short_name", False):
|
||||
return super()._compute_display_name()
|
||||
for record in self:
|
||||
record.display_name = record.name
|
||||
|
||||
@api.depends("expire_date")
|
||||
def _compute_expired(self):
|
||||
now = datetime.now()
|
||||
for rec in self:
|
||||
rec.expired = rec.expire_date and now > rec.expire_date
|
||||
|
||||
def _search_expired(self, operator, value):
|
||||
if (operator not in ["=", "!="]) or (value not in [True, False]):
|
||||
return []
|
||||
|
||||
if (operator, value) in [("=", True), ("!=", False)]:
|
||||
return [("expire_date", "<", datetime.now())]
|
||||
|
||||
return ["|", ("expire_date", ">=", datetime.now()), ("expire_date", "=", False)]
|
||||
|
||||
def log_change(self, action):
|
||||
if self.env.context.get("vault_skip_log"):
|
||||
return
|
||||
|
||||
for rec in self:
|
||||
rec.log_info(
|
||||
_("%(action)s entry %(name)s by %(user)s")
|
||||
% {
|
||||
"action": action,
|
||||
"name": rec.complete_name,
|
||||
"user": rec.env.user.display_name,
|
||||
}
|
||||
)
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
res = super().create(vals_list)
|
||||
res.log_change("Created")
|
||||
return res
|
||||
|
||||
def unlink(self):
|
||||
self.log_change("Deleted")
|
||||
|
||||
return super().unlink()
|
||||
|
||||
def _log_entry(self, msg, state):
|
||||
self.ensure_one()
|
||||
return (
|
||||
self.env["vault.log"]
|
||||
.sudo()
|
||||
.create(
|
||||
{
|
||||
"vault_id": self.vault_id.id,
|
||||
"entry_id": self.id,
|
||||
"user_id": self.env.uid,
|
||||
"message": msg,
|
||||
"state": state,
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
def action_open_import_wizard(self):
|
||||
self.ensure_one()
|
||||
wizard = self.env.ref("vault.view_vault_import_wizard")
|
||||
return {
|
||||
"name": _("Import from file"),
|
||||
"type": "ir.actions.act_window",
|
||||
"view_mode": "form",
|
||||
"res_model": "vault.import.wizard",
|
||||
"views": [(wizard.id, "form")],
|
||||
"view_id": wizard.id,
|
||||
"target": "new",
|
||||
"context": {
|
||||
"default_vault_id": self.vault_id.id,
|
||||
"default_parent_id": self.id,
|
||||
},
|
||||
}
|
||||
|
||||
def action_open_export_wizard(self):
|
||||
self.ensure_one()
|
||||
wizard = self.env.ref("vault.view_vault_export_wizard")
|
||||
return {
|
||||
"name": _("Export to file"),
|
||||
"type": "ir.actions.act_window",
|
||||
"view_mode": "form",
|
||||
"res_model": "vault.export.wizard",
|
||||
"views": [(wizard.id, "form")],
|
||||
"view_id": wizard.id,
|
||||
"target": "new",
|
||||
"context": {
|
||||
"default_vault_id": self.vault_id.id,
|
||||
"default_entry_id": self.id,
|
||||
},
|
||||
}
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
# © 2021 Florian Kantelberg - initOS GmbH
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
||||
|
||||
import logging
|
||||
|
||||
from odoo import _, fields, models
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class VaultField(models.Model):
|
||||
_name = "vault.field"
|
||||
_description = _("Field of a vault")
|
||||
_order = "name"
|
||||
_inherit = ["vault.abstract.field", "vault.abstract"]
|
||||
|
||||
value = fields.Char(required=True)
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
# © 2021 Florian Kantelberg - initOS GmbH
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
||||
|
||||
import logging
|
||||
|
||||
from odoo import _, api, fields, models
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class VaultFile(models.Model):
|
||||
_name = "vault.file"
|
||||
_description = _("File of a vault")
|
||||
_order = "name"
|
||||
_inherit = ["vault.abstract.field", "vault.abstract"]
|
||||
|
||||
value = fields.Binary(attachment=False)
|
||||
|
||||
@api.model
|
||||
def search_read(self, *args, **kwargs):
|
||||
if self.env.context.get("vault_reencrypt"):
|
||||
return super(VaultFile, self.with_context(bin_size=False)).search_read(
|
||||
*args, **kwargs
|
||||
)
|
||||
return super().search_read(*args, **kwargs)
|
||||
114
odoo-bringout-oca-server-auth-vault/vault/models/vault_inbox.py
Normal file
114
odoo-bringout-oca-server-auth-vault/vault/models/vault_inbox.py
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
# © 2021 Florian Kantelberg - initOS GmbH
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from uuid import uuid4
|
||||
|
||||
from odoo import _, api, fields, models
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class VaultInbox(models.Model):
|
||||
_name = "vault.inbox"
|
||||
_description = _("Vault share incoming secrets")
|
||||
|
||||
token = fields.Char(default=lambda self: uuid4(), readonly=True, copy=False)
|
||||
inbox_link = fields.Char(
|
||||
compute="_compute_inbox_link",
|
||||
readonly=True,
|
||||
help="Using this link you can write to the current inbox. If you want people "
|
||||
"to create new inboxes you should give them your inbox link from your key "
|
||||
"management.",
|
||||
)
|
||||
user_id = fields.Many2one(
|
||||
"res.users",
|
||||
"Vault",
|
||||
required=True,
|
||||
)
|
||||
name = fields.Char(required=True)
|
||||
secret = fields.Char(readonly=True)
|
||||
filename = fields.Char()
|
||||
secret_file = fields.Binary(attachment=False, readonly=True)
|
||||
key = fields.Char(required=True)
|
||||
iv = fields.Char(required=True)
|
||||
accesses = fields.Integer(
|
||||
"Access counter",
|
||||
default=1,
|
||||
help="If this is 0 the inbox can't be written using the link",
|
||||
)
|
||||
expiration = fields.Datetime(
|
||||
default=lambda self: datetime.now() + timedelta(days=7),
|
||||
help="If expired the inbox can't be written using the link",
|
||||
)
|
||||
log_ids = fields.One2many("vault.inbox.log", "inbox_id", "Log", readonly=True)
|
||||
|
||||
_sql_constraints = [
|
||||
(
|
||||
"value_check",
|
||||
"CHECK(secret IS NOT NULL OR secret_file IS NOT NULL)",
|
||||
_("No value found"),
|
||||
),
|
||||
]
|
||||
|
||||
@api.depends("token")
|
||||
def _compute_inbox_link(self):
|
||||
base_url = self.env["ir.config_parameter"].sudo().get_param("web.base.url")
|
||||
for rec in self:
|
||||
rec.inbox_link = f"{base_url}/vault/inbox/{rec.token}"
|
||||
|
||||
def read(self, *args, **kwargs):
|
||||
# Always load the binary instead of the size
|
||||
return super(VaultInbox, self.with_context(bin_size=False)).read(
|
||||
*args, **kwargs
|
||||
)
|
||||
|
||||
@api.model
|
||||
def find_inbox(self, token):
|
||||
return self.search([("token", "=", token)])
|
||||
|
||||
def store_in_inbox(
|
||||
self,
|
||||
name,
|
||||
secret,
|
||||
secret_file,
|
||||
iv,
|
||||
key,
|
||||
user,
|
||||
filename,
|
||||
ip=None,
|
||||
):
|
||||
log_info = {"name": user.name, "ip": ip or "n/a"}
|
||||
if len(self) == 0:
|
||||
log = _("Created by %(name)s via %(ip)s") % log_info
|
||||
return self.create(
|
||||
{
|
||||
"name": name,
|
||||
"accesses": 0,
|
||||
"iv": iv,
|
||||
"key": key,
|
||||
"secret": secret or None,
|
||||
"secret_file": secret_file or None,
|
||||
"filename": filename,
|
||||
"user_id": user.id,
|
||||
"log_ids": [(0, 0, {"name": log})],
|
||||
}
|
||||
)
|
||||
|
||||
self.ensure_one()
|
||||
if self.accesses > 0 and datetime.now() < self.expiration:
|
||||
log = _("Written by %(name)s via %(ip)s") % log_info
|
||||
|
||||
self.write(
|
||||
{
|
||||
"accesses": self.accesses - 1,
|
||||
"iv": iv,
|
||||
"key": key,
|
||||
"secret": secret or None,
|
||||
"secret_file": secret_file or None,
|
||||
"filename": filename,
|
||||
"log_ids": [(0, 0, {"name": log})],
|
||||
}
|
||||
)
|
||||
return self
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
# © 2021 Florian Kantelberg - initOS GmbH
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
||||
|
||||
import logging
|
||||
|
||||
from odoo import _, fields, models
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class VaultInboxLog(models.Model):
|
||||
_name = "vault.inbox.log"
|
||||
_description = _("Vault inbox log")
|
||||
_order = "create_date DESC"
|
||||
|
||||
inbox_id = fields.Many2one(
|
||||
"vault.inbox",
|
||||
ondelete="cascade",
|
||||
readonly=True,
|
||||
required=True,
|
||||
)
|
||||
name = fields.Char(readonly=True)
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
# © 2021 Florian Kantelberg - initOS GmbH
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
||||
|
||||
import logging
|
||||
|
||||
from odoo import _, api, fields, models
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class VaultLog(models.Model):
|
||||
_name = "vault.log"
|
||||
_description = _("Log entry of a vault")
|
||||
_order = "create_date DESC"
|
||||
_rec_name = "message"
|
||||
|
||||
vault_id = fields.Many2one(
|
||||
"vault",
|
||||
"Vault",
|
||||
ondelete="cascade",
|
||||
required=True,
|
||||
readonly=True,
|
||||
)
|
||||
entry_id = fields.Many2one(
|
||||
"vault.entry",
|
||||
"Entry",
|
||||
ondelete="cascade",
|
||||
readonly=True,
|
||||
)
|
||||
user_id = fields.Many2one("res.users", "User", required=True, readonly=True)
|
||||
state = fields.Selection(lambda self: self._get_log_state(), readonly=True)
|
||||
message = fields.Char(readonly=True, required=True)
|
||||
|
||||
def _get_log_state(self):
|
||||
return [
|
||||
("info", _("Information")),
|
||||
("warn", _("Warning")),
|
||||
("error", _("Error")),
|
||||
]
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
res = super().create(vals_list)
|
||||
if not self.env.context.get("skip_log", False):
|
||||
_logger.info("Vault log: %s", res.message)
|
||||
return res
|
||||
110
odoo-bringout-oca-server-auth-vault/vault/models/vault_right.py
Normal file
110
odoo-bringout-oca-server-auth-vault/vault/models/vault_right.py
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
# © 2021 Florian Kantelberg - initOS GmbH
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
||||
|
||||
from odoo import _, api, fields, models
|
||||
|
||||
|
||||
class VaultRight(models.Model):
|
||||
_name = "vault.right"
|
||||
_description = _("Vault rights")
|
||||
_inherit = ["vault.abstract"]
|
||||
_order = "user_id"
|
||||
|
||||
vault_id = fields.Many2one(
|
||||
"vault",
|
||||
"Vault",
|
||||
readonly=True,
|
||||
required=True,
|
||||
ondelete="cascade",
|
||||
)
|
||||
master_key = fields.Char(related="vault_id.master_key", readonly=True, store=False)
|
||||
user_id = fields.Many2one(
|
||||
"res.users",
|
||||
"User",
|
||||
domain=[("keys", "!=", False)],
|
||||
required=True,
|
||||
)
|
||||
public_key = fields.Char(compute="_compute_public_key", readonly=True, store=False)
|
||||
perm_create = fields.Boolean(
|
||||
"Create",
|
||||
default=lambda self: self._get_is_owner(),
|
||||
help="Allow to create in the vault",
|
||||
)
|
||||
perm_write = fields.Boolean(
|
||||
"Write",
|
||||
default=lambda self: self._get_is_owner(),
|
||||
help="Allow to write to the vault",
|
||||
)
|
||||
perm_share = fields.Boolean(
|
||||
"Share",
|
||||
default=lambda self: self._get_is_owner(),
|
||||
help="Allow to share a vault with new users",
|
||||
)
|
||||
perm_delete = fields.Boolean(
|
||||
"Delete",
|
||||
default=lambda self: self._get_is_owner(),
|
||||
help="Allow to delete a vault",
|
||||
)
|
||||
|
||||
perm_user = fields.Many2one(related="vault_id.perm_user", store=False)
|
||||
allowed_read = fields.Boolean(related="vault_id.allowed_read", store=False)
|
||||
allowed_create = fields.Boolean(related="vault_id.allowed_create", store=False)
|
||||
allowed_write = fields.Boolean(related="vault_id.allowed_write", store=False)
|
||||
allowed_share = fields.Boolean(related="vault_id.allowed_share", store=False)
|
||||
allowed_delete = fields.Boolean(related="vault_id.allowed_delete", store=False)
|
||||
|
||||
# Encrypted with the public key of the user
|
||||
key = fields.Char()
|
||||
|
||||
_sql_constraints = (
|
||||
("user_uniq", "UNIQUE(user_id, vault_id)", _("The user must be unique")),
|
||||
)
|
||||
|
||||
def _get_is_owner(self):
|
||||
return self.env.user == self.vault_id.user_id
|
||||
|
||||
@api.depends("user_id")
|
||||
def _compute_public_key(self):
|
||||
for rec in self:
|
||||
rec.public_key = rec.user_id.active_key.public
|
||||
|
||||
def log_access(self):
|
||||
for rec in self:
|
||||
rights = ", ".join(
|
||||
sorted(
|
||||
["read"]
|
||||
+ [
|
||||
right
|
||||
for right in ["create", "write", "share", "delete"]
|
||||
if getattr(rec, f"perm_{right}", False)
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
rec.vault_id.log_info(
|
||||
f"Grant access to user {rec.user_id.display_name}: {rights}"
|
||||
)
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
res = super().create(vals_list)
|
||||
if not res.allowed_share and not res.env.su:
|
||||
self.raise_access_error()
|
||||
|
||||
res.log_access()
|
||||
return res
|
||||
|
||||
def write(self, values):
|
||||
res = super().write(values)
|
||||
perms = ["perm_write", "perm_delete", "perm_share", "perm_create"]
|
||||
if any(x in values for x in perms):
|
||||
self.log_access()
|
||||
|
||||
return res
|
||||
|
||||
def unlink(self):
|
||||
for rec in self:
|
||||
rec.vault_id.log_info(f"Removed user {self.user_id.display_name}")
|
||||
rec.vault_id.reencrypt_required = True
|
||||
|
||||
return super().unlink()
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
# © 2021 Florian Kantelberg - initOS GmbH
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
||||
|
||||
from odoo import _, fields, models
|
||||
|
||||
|
||||
class VaultTag(models.Model):
|
||||
_name = "vault.tag"
|
||||
_description = _("Vault tag")
|
||||
_order = "name"
|
||||
|
||||
name = fields.Char(required=True)
|
||||
|
||||
_sql_constraints = [
|
||||
("name_uniq", "unique(name)", _("The tag must be unique!")),
|
||||
]
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
* Florian Kantelberg <florian.kantelberg@initos.com>
|
||||
* `Tecnativa <https://www.tecnativa.com>`_:
|
||||
|
||||
* Carlos Roca
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
This module implements a vault for secrets and files using end-to-end-encryption. The encryption and decryption happens in the browser using a vault specific shared master key. The master keys are encrypted using asymmetrically. For this the user has to enter a second password on the first login or if he needs to access data in a vault. The asymmetric keys are stored for a certain time in the browser storage.
|
||||
|
||||
The server can never access the secrets with the information available. Only people registered in the vault can decrypt or encrypt values in a vault. The meta data isn't encrypted to be able to search/filter for entries more easily.
|
||||
|
||||
This modules requires a secure context for the browser to work properly and therefore HTTPS support is required.
|
||||
|
||||
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.
|
||||
14
odoo-bringout-oca-server-auth-vault/vault/readme/ROADMAP.rst
Normal file
14
odoo-bringout-oca-server-auth-vault/vault/readme/ROADMAP.rst
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
* Field and file history for restoration
|
||||
|
||||
* Import improvement
|
||||
|
||||
* Support challenge-response/FIDO2
|
||||
* Support for argon2 and kdbx v4
|
||||
|
||||
* When changing an entry from one vault to another existing vault, the values added on
|
||||
this entry cannot be accessed, so the field vault is going to be readonly when it
|
||||
is defined.
|
||||
|
||||
If you want to move entries between vaults you can use the export -> import option.
|
||||
|
||||
* HTTPS or localhost (secure browser context) is required for the client side encryption
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||
access_vault,access_vault,model_vault,base.group_user,1,1,1,1
|
||||
access_vault_entry,access_vault_entry,model_vault_entry,base.group_user,1,1,1,1
|
||||
access_vault_export_wizard,access_vault_export_wizard,model_vault_export_wizard,base.group_user,1,1,1,1
|
||||
access_vault_field,access_vault_field,model_vault_field,base.group_user,1,1,1,1
|
||||
access_vault_file,access_vault_file,model_vault_file,base.group_user,1,1,1,1
|
||||
access_vault_import_wizard,access_vault_import_wizard,model_vault_import_wizard,base.group_user,1,1,1,1
|
||||
access_vault_import_wizard_path,access_vault_import_wizard_path,model_vault_import_wizard_path,base.group_user,1,1,1,1
|
||||
access_vault_inbox,access_vault_inbox,model_vault_inbox,base.group_user,1,1,1,1
|
||||
access_vault_inbox_log,access_vault_inbox_log,model_vault_inbox_log,base.group_user,1,1,1,1
|
||||
access_vault_log,access_vault_log,model_vault_log,base.group_user,1,0,0,0
|
||||
access_vault_right,access_vault_right,model_vault_right,base.group_user,1,1,1,1
|
||||
access_vault_send_wizard,access_vault_send_wizard,model_vault_send_wizard,base.group_user,1,1,1,1
|
||||
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
|
||||
|
111
odoo-bringout-oca-server-auth-vault/vault/security/ir_rule.xml
Normal file
111
odoo-bringout-oca-server-auth-vault/vault/security/ir_rule.xml
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<odoo>
|
||||
<record id="vault_access_default" model="ir.rule">
|
||||
<field name="name">vault.access.default</field>
|
||||
<field name="model_id" ref="vault.model_vault" />
|
||||
<field
|
||||
name="domain_force"
|
||||
>['|', ('user_id', '=', user.id), ('right_ids.user_id', '=', user.id)]</field>
|
||||
<field name="groups" eval="[(4, ref('base.group_user'))]" />
|
||||
<field name="perm_create" eval="1" />
|
||||
<field name="perm_write" eval="1" />
|
||||
<field name="perm_unlink" eval="1" />
|
||||
<field name="perm_read" eval="1" />
|
||||
</record>
|
||||
|
||||
<record id="vault_log_access_default" model="ir.rule">
|
||||
<field name="name">vault.log.access.read</field>
|
||||
<field name="model_id" ref="vault.model_vault_log" />
|
||||
<field
|
||||
name="domain_force"
|
||||
>['|', ('vault_id.user_id', '=', user.id), ('vault_id.right_ids.user_id', '=', user.id)]</field>
|
||||
<field name="groups" eval="[(4, ref('base.group_user'))]" />
|
||||
<field name="perm_create" eval="1" />
|
||||
<field name="perm_write" eval="1" />
|
||||
<field name="perm_unlink" eval="1" />
|
||||
<field name="perm_read" eval="1" />
|
||||
</record>
|
||||
|
||||
<record id="vault_entry_access_default" model="ir.rule">
|
||||
<field name="name">vault.entry.access.default</field>
|
||||
<field name="model_id" ref="vault.model_vault_entry" />
|
||||
<field
|
||||
name="domain_force"
|
||||
>['|', ('vault_id.user_id', '=', user.id), ('vault_id.right_ids.user_id', '=', user.id)]</field>
|
||||
<field name="groups" eval="[(4, ref('base.group_user'))]" />
|
||||
<field name="perm_create" eval="1" />
|
||||
<field name="perm_write" eval="1" />
|
||||
<field name="perm_unlink" eval="1" />
|
||||
<field name="perm_read" eval="1" />
|
||||
</record>
|
||||
|
||||
<record id="vault_field_access_default" model="ir.rule">
|
||||
<field name="name">vault.field.access.default</field>
|
||||
<field name="model_id" ref="vault.model_vault_field" />
|
||||
<field
|
||||
name="domain_force"
|
||||
>['|', ('vault_id.user_id', '=', user.id), ('vault_id.right_ids.user_id', '=', user.id)]</field>
|
||||
<field name="groups" eval="[(4, ref('base.group_user'))]" />
|
||||
<field name="perm_create" eval="1" />
|
||||
<field name="perm_write" eval="1" />
|
||||
<field name="perm_unlink" eval="1" />
|
||||
<field name="perm_read" eval="1" />
|
||||
</record>
|
||||
|
||||
<record id="vault_file_access_default" model="ir.rule">
|
||||
<field name="name">vault.file.access.default</field>
|
||||
<field name="model_id" ref="vault.model_vault_file" />
|
||||
<field
|
||||
name="domain_force"
|
||||
>['|', ('vault_id.user_id', '=', user.id), ('vault_id.right_ids.user_id', '=', user.id)]</field>
|
||||
<field name="groups" eval="[(4, ref('base.group_user'))]" />
|
||||
<field name="perm_create" eval="1" />
|
||||
<field name="perm_write" eval="1" />
|
||||
<field name="perm_unlink" eval="1" />
|
||||
<field name="perm_read" eval="1" />
|
||||
</record>
|
||||
|
||||
<record id="res_users_key_access_default" model="ir.rule">
|
||||
<field name="name">res.users.key.access.default</field>
|
||||
<field name="model_id" ref="vault.model_res_users_key" />
|
||||
<field name="domain_force">[('user_id', '=', user.id)]</field>
|
||||
<field name="groups" eval="[(4, ref('base.group_user'))]" />
|
||||
<field name="perm_create" eval="1" />
|
||||
<field name="perm_write" eval="1" />
|
||||
<field name="perm_unlink" eval="1" />
|
||||
<field name="perm_read" eval="1" />
|
||||
</record>
|
||||
|
||||
<record id="vault_inbox_owner" model="ir.rule">
|
||||
<field name="name">vault.inbox.access.owner</field>
|
||||
<field name="model_id" ref="vault.model_vault_inbox" />
|
||||
<field name="domain_force">[('user_id', '=', user.id)]</field>
|
||||
<field name="perm_create" eval="1" />
|
||||
<field name="perm_write" eval="1" />
|
||||
<field name="perm_unlink" eval="1" />
|
||||
<field name="perm_read" eval="1" />
|
||||
</record>
|
||||
|
||||
<record id="vault_right_access_default" model="ir.rule">
|
||||
<field name="name">vault.right.access.default</field>
|
||||
<field name="model_id" ref="vault.model_vault_right" />
|
||||
<field
|
||||
name="domain_force"
|
||||
>[('vault_id.right_ids.user_id', '=', user.id)]</field>
|
||||
<field name="groups" eval="[(4, ref('base.group_user'))]" />
|
||||
<field name="perm_create" eval="1" />
|
||||
<field name="perm_write" eval="1" />
|
||||
<field name="perm_unlink" eval="1" />
|
||||
<field name="perm_read" eval="1" />
|
||||
</record>
|
||||
|
||||
<record id="vault_inbox_log_owner" model="ir.rule">
|
||||
<field name="name">vault.inbox.log.access.owner</field>
|
||||
<field name="model_id" ref="vault.model_vault_inbox_log" />
|
||||
<field name="domain_force">[('inbox_id.user_id', '=', user.id)]</field>
|
||||
<field name="perm_create" eval="1" />
|
||||
<field name="perm_write" eval="1" />
|
||||
<field name="perm_unlink" eval="1" />
|
||||
<field name="perm_read" eval="1" />
|
||||
</record>
|
||||
</odoo>
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<odoo>
|
||||
<record id="group_vault_export" model="res.groups">
|
||||
<field name="name">Allow to export vaults</field>
|
||||
<field name="category_id" ref="base.module_category_hidden" />
|
||||
<field name="users" eval="[(4, ref('base.user_admin'))]" />
|
||||
</record>
|
||||
<record id="group_vault_import" model="res.groups">
|
||||
<field name="name">Allow to import vaults</field>
|
||||
<field name="category_id" ref="base.module_category_hidden" />
|
||||
<field name="users" eval="[(4, ref('base.user_admin'))]" />
|
||||
</record>
|
||||
</odoo>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 8.5 KiB |
|
|
@ -0,0 +1,453 @@
|
|||
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
|
||||
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
|
||||
<head>
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
|
||||
<meta name="generator" content="Docutils: https://docutils.sourceforge.io/" />
|
||||
<title>Vault</title>
|
||||
<style type="text/css">
|
||||
|
||||
/*
|
||||
:Author: David Goodger (goodger@python.org)
|
||||
:Id: $Id: html4css1.css 9511 2024-01-13 09:50:07Z milde $
|
||||
:Copyright: This stylesheet has been placed in the public domain.
|
||||
|
||||
Default cascading style sheet for the HTML output of Docutils.
|
||||
Despite the name, some widely supported CSS2 features are used.
|
||||
|
||||
See https://docutils.sourceforge.io/docs/howto/html-stylesheets.html for how to
|
||||
customize this style sheet.
|
||||
*/
|
||||
|
||||
/* used to remove borders from tables and images */
|
||||
.borderless, table.borderless td, table.borderless th {
|
||||
border: 0 }
|
||||
|
||||
table.borderless td, table.borderless th {
|
||||
/* Override padding for "table.docutils td" with "! important".
|
||||
The right padding separates the table cells. */
|
||||
padding: 0 0.5em 0 0 ! important }
|
||||
|
||||
.first {
|
||||
/* Override more specific margin styles with "! important". */
|
||||
margin-top: 0 ! important }
|
||||
|
||||
.last, .with-subtitle {
|
||||
margin-bottom: 0 ! important }
|
||||
|
||||
.hidden {
|
||||
display: none }
|
||||
|
||||
.subscript {
|
||||
vertical-align: sub;
|
||||
font-size: smaller }
|
||||
|
||||
.superscript {
|
||||
vertical-align: super;
|
||||
font-size: smaller }
|
||||
|
||||
a.toc-backref {
|
||||
text-decoration: none ;
|
||||
color: black }
|
||||
|
||||
blockquote.epigraph {
|
||||
margin: 2em 5em ; }
|
||||
|
||||
dl.docutils dd {
|
||||
margin-bottom: 0.5em }
|
||||
|
||||
object[type="image/svg+xml"], object[type="application/x-shockwave-flash"] {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Uncomment (and remove this text!) to get bold-faced definition list terms
|
||||
dl.docutils dt {
|
||||
font-weight: bold }
|
||||
*/
|
||||
|
||||
div.abstract {
|
||||
margin: 2em 5em }
|
||||
|
||||
div.abstract p.topic-title {
|
||||
font-weight: bold ;
|
||||
text-align: center }
|
||||
|
||||
div.admonition, div.attention, div.caution, div.danger, div.error,
|
||||
div.hint, div.important, div.note, div.tip, div.warning {
|
||||
margin: 2em ;
|
||||
border: medium outset ;
|
||||
padding: 1em }
|
||||
|
||||
div.admonition p.admonition-title, div.hint p.admonition-title,
|
||||
div.important p.admonition-title, div.note p.admonition-title,
|
||||
div.tip p.admonition-title {
|
||||
font-weight: bold ;
|
||||
font-family: sans-serif }
|
||||
|
||||
div.attention p.admonition-title, div.caution p.admonition-title,
|
||||
div.danger p.admonition-title, div.error p.admonition-title,
|
||||
div.warning p.admonition-title, .code .error {
|
||||
color: red ;
|
||||
font-weight: bold ;
|
||||
font-family: sans-serif }
|
||||
|
||||
/* Uncomment (and remove this text!) to get reduced vertical space in
|
||||
compound paragraphs.
|
||||
div.compound .compound-first, div.compound .compound-middle {
|
||||
margin-bottom: 0.5em }
|
||||
|
||||
div.compound .compound-last, div.compound .compound-middle {
|
||||
margin-top: 0.5em }
|
||||
*/
|
||||
|
||||
div.dedication {
|
||||
margin: 2em 5em ;
|
||||
text-align: center ;
|
||||
font-style: italic }
|
||||
|
||||
div.dedication p.topic-title {
|
||||
font-weight: bold ;
|
||||
font-style: normal }
|
||||
|
||||
div.figure {
|
||||
margin-left: 2em ;
|
||||
margin-right: 2em }
|
||||
|
||||
div.footer, div.header {
|
||||
clear: both;
|
||||
font-size: smaller }
|
||||
|
||||
div.line-block {
|
||||
display: block ;
|
||||
margin-top: 1em ;
|
||||
margin-bottom: 1em }
|
||||
|
||||
div.line-block div.line-block {
|
||||
margin-top: 0 ;
|
||||
margin-bottom: 0 ;
|
||||
margin-left: 1.5em }
|
||||
|
||||
div.sidebar {
|
||||
margin: 0 0 0.5em 1em ;
|
||||
border: medium outset ;
|
||||
padding: 1em ;
|
||||
background-color: #ffffee ;
|
||||
width: 40% ;
|
||||
float: right ;
|
||||
clear: right }
|
||||
|
||||
div.sidebar p.rubric {
|
||||
font-family: sans-serif ;
|
||||
font-size: medium }
|
||||
|
||||
div.system-messages {
|
||||
margin: 5em }
|
||||
|
||||
div.system-messages h1 {
|
||||
color: red }
|
||||
|
||||
div.system-message {
|
||||
border: medium outset ;
|
||||
padding: 1em }
|
||||
|
||||
div.system-message p.system-message-title {
|
||||
color: red ;
|
||||
font-weight: bold }
|
||||
|
||||
div.topic {
|
||||
margin: 2em }
|
||||
|
||||
h1.section-subtitle, h2.section-subtitle, h3.section-subtitle,
|
||||
h4.section-subtitle, h5.section-subtitle, h6.section-subtitle {
|
||||
margin-top: 0.4em }
|
||||
|
||||
h1.title {
|
||||
text-align: center }
|
||||
|
||||
h2.subtitle {
|
||||
text-align: center }
|
||||
|
||||
hr.docutils {
|
||||
width: 75% }
|
||||
|
||||
img.align-left, .figure.align-left, object.align-left, table.align-left {
|
||||
clear: left ;
|
||||
float: left ;
|
||||
margin-right: 1em }
|
||||
|
||||
img.align-right, .figure.align-right, object.align-right, table.align-right {
|
||||
clear: right ;
|
||||
float: right ;
|
||||
margin-left: 1em }
|
||||
|
||||
img.align-center, .figure.align-center, object.align-center {
|
||||
display: block;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
table.align-center {
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.align-left {
|
||||
text-align: left }
|
||||
|
||||
.align-center {
|
||||
clear: both ;
|
||||
text-align: center }
|
||||
|
||||
.align-right {
|
||||
text-align: right }
|
||||
|
||||
/* reset inner alignment in figures */
|
||||
div.align-right {
|
||||
text-align: inherit }
|
||||
|
||||
/* div.align-center * { */
|
||||
/* text-align: left } */
|
||||
|
||||
.align-top {
|
||||
vertical-align: top }
|
||||
|
||||
.align-middle {
|
||||
vertical-align: middle }
|
||||
|
||||
.align-bottom {
|
||||
vertical-align: bottom }
|
||||
|
||||
ol.simple, ul.simple {
|
||||
margin-bottom: 1em }
|
||||
|
||||
ol.arabic {
|
||||
list-style: decimal }
|
||||
|
||||
ol.loweralpha {
|
||||
list-style: lower-alpha }
|
||||
|
||||
ol.upperalpha {
|
||||
list-style: upper-alpha }
|
||||
|
||||
ol.lowerroman {
|
||||
list-style: lower-roman }
|
||||
|
||||
ol.upperroman {
|
||||
list-style: upper-roman }
|
||||
|
||||
p.attribution {
|
||||
text-align: right ;
|
||||
margin-left: 50% }
|
||||
|
||||
p.caption {
|
||||
font-style: italic }
|
||||
|
||||
p.credits {
|
||||
font-style: italic ;
|
||||
font-size: smaller }
|
||||
|
||||
p.label {
|
||||
white-space: nowrap }
|
||||
|
||||
p.rubric {
|
||||
font-weight: bold ;
|
||||
font-size: larger ;
|
||||
color: maroon ;
|
||||
text-align: center }
|
||||
|
||||
p.sidebar-title {
|
||||
font-family: sans-serif ;
|
||||
font-weight: bold ;
|
||||
font-size: larger }
|
||||
|
||||
p.sidebar-subtitle {
|
||||
font-family: sans-serif ;
|
||||
font-weight: bold }
|
||||
|
||||
p.topic-title {
|
||||
font-weight: bold }
|
||||
|
||||
pre.address {
|
||||
margin-bottom: 0 ;
|
||||
margin-top: 0 ;
|
||||
font: inherit }
|
||||
|
||||
pre.literal-block, pre.doctest-block, pre.math, pre.code {
|
||||
margin-left: 2em ;
|
||||
margin-right: 2em }
|
||||
|
||||
pre.code .ln { color: gray; } /* line numbers */
|
||||
pre.code, code { background-color: #eeeeee }
|
||||
pre.code .comment, code .comment { color: #5C6576 }
|
||||
pre.code .keyword, code .keyword { color: #3B0D06; font-weight: bold }
|
||||
pre.code .literal.string, code .literal.string { color: #0C5404 }
|
||||
pre.code .name.builtin, code .name.builtin { color: #352B84 }
|
||||
pre.code .deleted, code .deleted { background-color: #DEB0A1}
|
||||
pre.code .inserted, code .inserted { background-color: #A3D289}
|
||||
|
||||
span.classifier {
|
||||
font-family: sans-serif ;
|
||||
font-style: oblique }
|
||||
|
||||
span.classifier-delimiter {
|
||||
font-family: sans-serif ;
|
||||
font-weight: bold }
|
||||
|
||||
span.interpreted {
|
||||
font-family: sans-serif }
|
||||
|
||||
span.option {
|
||||
white-space: nowrap }
|
||||
|
||||
span.pre {
|
||||
white-space: pre }
|
||||
|
||||
span.problematic, pre.problematic {
|
||||
color: red }
|
||||
|
||||
span.section-subtitle {
|
||||
/* font-size relative to parent (h1..h6 element) */
|
||||
font-size: 80% }
|
||||
|
||||
table.citation {
|
||||
border-left: solid 1px gray;
|
||||
margin-left: 1px }
|
||||
|
||||
table.docinfo {
|
||||
margin: 2em 4em }
|
||||
|
||||
table.docutils {
|
||||
margin-top: 0.5em ;
|
||||
margin-bottom: 0.5em }
|
||||
|
||||
table.footnote {
|
||||
border-left: solid 1px black;
|
||||
margin-left: 1px }
|
||||
|
||||
table.docutils td, table.docutils th,
|
||||
table.docinfo td, table.docinfo th {
|
||||
padding-left: 0.5em ;
|
||||
padding-right: 0.5em ;
|
||||
vertical-align: top }
|
||||
|
||||
table.docutils th.field-name, table.docinfo th.docinfo-name {
|
||||
font-weight: bold ;
|
||||
text-align: left ;
|
||||
white-space: nowrap ;
|
||||
padding-left: 0 }
|
||||
|
||||
/* "booktabs" style (no vertical lines) */
|
||||
table.docutils.booktabs {
|
||||
border: 0px;
|
||||
border-top: 2px solid;
|
||||
border-bottom: 2px solid;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
table.docutils.booktabs * {
|
||||
border: 0px;
|
||||
}
|
||||
table.docutils.booktabs th {
|
||||
border-bottom: thin solid;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
h1 tt.docutils, h2 tt.docutils, h3 tt.docutils,
|
||||
h4 tt.docutils, h5 tt.docutils, h6 tt.docutils {
|
||||
font-size: 100% }
|
||||
|
||||
ul.auto-toc {
|
||||
list-style-type: none }
|
||||
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="document" id="vault">
|
||||
<h1 class="title">Vault</h1>
|
||||
|
||||
<!-- !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
|
||||
!! This file is generated by oca-gen-addon-readme !!
|
||||
!! changes will be overwritten. !!
|
||||
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
|
||||
!! source digest: sha256:c0c446463d63752dc25080d52e0132ae87b0b43fd51edc7915ed1b919763b40e
|
||||
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -->
|
||||
<p><a class="reference external image-reference" href="https://odoo-community.org/page/development-status"><img alt="Beta" src="https://img.shields.io/badge/maturity-Beta-yellow.png" /></a> <a class="reference external image-reference" href="http://www.gnu.org/licenses/agpl-3.0-standalone.html"><img alt="License: AGPL-3" src="https://img.shields.io/badge/licence-AGPL--3-blue.png" /></a> <a class="reference external image-reference" href="https://github.com/OCA/server-auth/tree/16.0/vault"><img alt="OCA/server-auth" src="https://img.shields.io/badge/github-OCA%2Fserver--auth-lightgray.png?logo=github" /></a> <a class="reference external image-reference" href="https://translation.odoo-community.org/projects/server-auth-16-0/server-auth-16-0-vault"><img alt="Translate me on Weblate" src="https://img.shields.io/badge/weblate-Translate%20me-F47D42.png" /></a> <a class="reference external image-reference" href="https://runboat.odoo-community.org/builds?repo=OCA/server-auth&target_branch=16.0"><img alt="Try me on Runboat" src="https://img.shields.io/badge/runboat-Try%20me-875A7B.png" /></a></p>
|
||||
<p>This module implements a vault for secrets and files using end-to-end-encryption. The encryption and decryption happens in the browser using a vault specific shared master key. The master keys are encrypted using asymmetrically. For this the user has to enter a second password on the first login or if he needs to access data in a vault. The asymmetric keys are stored for a certain time in the browser storage.</p>
|
||||
<p>The server can never access the secrets with the information available. Only people registered in the vault can decrypt or encrypt values in a vault. The meta data isn’t encrypted to be able to search/filter for entries more easily.</p>
|
||||
<p>This modules requires a secure context for the browser to work properly and therefore HTTPS support is required.</p>
|
||||
<p>The <a class="reference external" href="https://github.com/fkantelberg/vault-recovery">vault-recovery</a> project focuses on disaster recovery in case of an incident to recover secrets from old database backups or old exports.</p>
|
||||
<p><strong>Table of contents</strong></p>
|
||||
<div class="contents local topic" id="contents">
|
||||
<ul class="simple">
|
||||
<li><a class="reference internal" href="#known-issues-roadmap" id="toc-entry-1">Known issues / Roadmap</a></li>
|
||||
<li><a class="reference internal" href="#bug-tracker" id="toc-entry-2">Bug Tracker</a></li>
|
||||
<li><a class="reference internal" href="#credits" id="toc-entry-3">Credits</a><ul>
|
||||
<li><a class="reference internal" href="#authors" id="toc-entry-4">Authors</a></li>
|
||||
<li><a class="reference internal" href="#contributors" id="toc-entry-5">Contributors</a></li>
|
||||
<li><a class="reference internal" href="#maintainers" id="toc-entry-6">Maintainers</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="section" id="known-issues-roadmap">
|
||||
<h1><a class="toc-backref" href="#toc-entry-1">Known issues / Roadmap</a></h1>
|
||||
<ul class="simple">
|
||||
<li>Field and file history for restoration</li>
|
||||
<li>Import improvement</li>
|
||||
</ul>
|
||||
<blockquote>
|
||||
<ul class="simple">
|
||||
<li>Support challenge-response/FIDO2</li>
|
||||
<li>Support for argon2 and kdbx v4</li>
|
||||
</ul>
|
||||
</blockquote>
|
||||
<ul>
|
||||
<li><p class="first">When changing an entry from one vault to another existing vault, the values added on
|
||||
this entry cannot be accessed, so the field vault is going to be readonly when it
|
||||
is defined.</p>
|
||||
<p>If you want to move entries between vaults you can use the export -> import option.</p>
|
||||
</li>
|
||||
<li><p class="first">HTTPS or localhost (secure browser context) is required for the client side encryption</p>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="section" id="bug-tracker">
|
||||
<h1><a class="toc-backref" href="#toc-entry-2">Bug Tracker</a></h1>
|
||||
<p>Bugs are tracked on <a class="reference external" href="https://github.com/OCA/server-auth/issues">GitHub Issues</a>.
|
||||
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
|
||||
<a class="reference external" href="https://github.com/OCA/server-auth/issues/new?body=module:%20vault%0Aversion:%2016.0%0A%0A**Steps%20to%20reproduce**%0A-%20...%0A%0A**Current%20behavior**%0A%0A**Expected%20behavior**">feedback</a>.</p>
|
||||
<p>Do not contact contributors directly about support or help with technical issues.</p>
|
||||
</div>
|
||||
<div class="section" id="credits">
|
||||
<h1><a class="toc-backref" href="#toc-entry-3">Credits</a></h1>
|
||||
<div class="section" id="authors">
|
||||
<h2><a class="toc-backref" href="#toc-entry-4">Authors</a></h2>
|
||||
<ul class="simple">
|
||||
<li>initOS GmbH</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="section" id="contributors">
|
||||
<h2><a class="toc-backref" href="#toc-entry-5">Contributors</a></h2>
|
||||
<ul class="simple">
|
||||
<li>Florian Kantelberg <<a class="reference external" href="mailto:florian.kantelberg@initos.com">florian.kantelberg@initos.com</a>></li>
|
||||
<li><a class="reference external" href="https://www.tecnativa.com">Tecnativa</a>:<ul>
|
||||
<li>Carlos Roca</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="section" id="maintainers">
|
||||
<h2><a class="toc-backref" href="#toc-entry-6">Maintainers</a></h2>
|
||||
<p>This module is maintained by the OCA.</p>
|
||||
<a class="reference external image-reference" href="https://odoo-community.org">
|
||||
<img alt="Odoo Community Association" src="https://odoo-community.org/logo.png" />
|
||||
</a>
|
||||
<p>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.</p>
|
||||
<p>This module is part of the <a class="reference external" href="https://github.com/OCA/server-auth/tree/16.0/vault">OCA/server-auth</a> project on GitHub.</p>
|
||||
<p>You are welcome to contribute. To learn how please visit <a class="reference external" href="https://odoo-community.org/page/Contribute">https://odoo-community.org/page/Contribute</a>.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
2
odoo-bringout-oca-server-auth-vault/vault/static/lib/kdbxweb/kdbxweb.min.js
vendored
Normal file
2
odoo-bringout-oca-server-auth-vault/vault/static/lib/kdbxweb/kdbxweb.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -0,0 +1,413 @@
|
|||
/** @odoo-module alias=vault.controller **/
|
||||
// © 2021-2024 Florian Kantelberg - initOS GmbH
|
||||
// License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
||||
|
||||
import {AlertDialog} from "@web/core/confirmation_dialog/confirmation_dialog";
|
||||
import Dialog from "web.Dialog";
|
||||
import {FormController} from "@web/views/form/form_controller";
|
||||
import Importer from "vault.import";
|
||||
import {ListController} from "@web/views/list/list_controller";
|
||||
import {_lt} from "@web/core/l10n/translation";
|
||||
import framework from "web.framework";
|
||||
import {patch} from "@web/core/utils/patch";
|
||||
import {useService} from "@web/core/utils/hooks";
|
||||
import utils from "vault.utils";
|
||||
import vault from "vault";
|
||||
|
||||
patch(FormController.prototype, "vault", {
|
||||
/**
|
||||
* Re-encrypt the key if the user is getting selected
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
async _vaultSendWizard() {
|
||||
const record = this.model.root;
|
||||
if (record.resModel !== "vault.send.wizard") return;
|
||||
|
||||
if (!record.data.user_id || !record.data.public) return;
|
||||
|
||||
const key = await vault.unwrap(record.data.key);
|
||||
await record.update({key_user: await vault.wrap_with(key, record.data.public)});
|
||||
},
|
||||
|
||||
/**
|
||||
* Re-encrypt the key if the entry is getting selected
|
||||
*
|
||||
* @private
|
||||
* @param {Object} record
|
||||
* @param {Object} changes
|
||||
* @param {Object} options
|
||||
*/
|
||||
async _vaultStoreWizard() {
|
||||
const record = this.model.root;
|
||||
if (
|
||||
!record.data.entry_id ||
|
||||
!record.data.master_key ||
|
||||
!record.data.iv ||
|
||||
!record.data.secret_temporary
|
||||
)
|
||||
return;
|
||||
|
||||
const key = await vault.unwrap(record.data.key);
|
||||
const secret = await utils.sym_decrypt(
|
||||
key,
|
||||
record.data.secret_temporary,
|
||||
record.data.iv
|
||||
);
|
||||
const master_key = await vault.unwrap(record.data.master_key);
|
||||
|
||||
await record.update({
|
||||
secret: await utils.sym_encrypt(master_key, secret, record.data.iv),
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Generate a new key pair for the current user
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
async _newVaultKeyPair() {
|
||||
// Get the current private key
|
||||
const private_key = await vault.get_private_key();
|
||||
|
||||
// Generate new keys
|
||||
await vault.generate_keys();
|
||||
|
||||
const public_key = await vault.get_public_key();
|
||||
|
||||
// Re-encrypt the master keys
|
||||
const master_keys = await this.rpc("/vault/rights/get");
|
||||
let result = {};
|
||||
for (const uuid in master_keys) {
|
||||
result[uuid] = await utils.wrap(
|
||||
await utils.unwrap(master_keys[uuid], private_key),
|
||||
public_key
|
||||
);
|
||||
}
|
||||
|
||||
await this.rpc("/vault/rights/store", {keys: result});
|
||||
|
||||
// Re-encrypt the inboxes to not loose it
|
||||
const inbox_keys = await this.rpc("/vault/inbox/get");
|
||||
result = {};
|
||||
for (const uuid in inbox_keys) {
|
||||
result[uuid] = await utils.wrap(
|
||||
await utils.unwrap(inbox_keys[uuid], private_key),
|
||||
public_key
|
||||
);
|
||||
}
|
||||
|
||||
await this.rpc("/vault/inbox/store", {keys: result});
|
||||
},
|
||||
|
||||
/**
|
||||
* Generate a new key pair and re-encrypt the master keys of the vaults
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
async _vaultRegenerateKey() {
|
||||
if (!utils.supported()) return;
|
||||
|
||||
var self = this;
|
||||
|
||||
Dialog.confirm(
|
||||
self,
|
||||
_lt("Do you really want to create a new key pair and set it active?"),
|
||||
{
|
||||
confirm_callback: function () {
|
||||
return self._newVaultKeyPair();
|
||||
},
|
||||
}
|
||||
);
|
||||
},
|
||||
|
||||
/**
|
||||
* Handle the deletion of a vault.right field in the vault view properly by
|
||||
* generating a new master key and re-encrypting everything in the vault to
|
||||
* deny any future access to the vault.
|
||||
*
|
||||
* @private
|
||||
* @param {Boolean} verify
|
||||
* @param {Boolean} force
|
||||
*/
|
||||
async _reencryptVault(verify = false, force = false) {
|
||||
const record = this.model.root;
|
||||
|
||||
await vault._ensure_keys();
|
||||
|
||||
const self = this;
|
||||
const master_key = await utils.generate_key();
|
||||
const current_key = await vault.unwrap(record.data.master_key);
|
||||
|
||||
// This stores the additional changes made to rights, fields, and files
|
||||
const changes = [];
|
||||
const problems = [];
|
||||
|
||||
async function reencrypt(model, type) {
|
||||
// Load the entire data from the database
|
||||
const records = await self.model.orm.searchRead(
|
||||
model,
|
||||
[["vault_id", "=", record.resId]],
|
||||
["iv", "value", "name", "entry_name"],
|
||||
{
|
||||
context: {vault_reencrypt: true},
|
||||
limit: 0,
|
||||
}
|
||||
);
|
||||
|
||||
for (const rec of records) {
|
||||
const val = await utils.sym_decrypt(current_key, rec.value, rec.iv);
|
||||
if (val === null) {
|
||||
problems.push(
|
||||
_.str.sprintf(
|
||||
_lt("%s '%s' of entry '%s'"),
|
||||
type,
|
||||
rec.name,
|
||||
rec.entry_name
|
||||
)
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const iv = utils.generate_iv_base64();
|
||||
const encrypted = await utils.sym_encrypt(master_key, val, iv);
|
||||
|
||||
changes.push({
|
||||
id: rec.id,
|
||||
model: model,
|
||||
value: encrypted,
|
||||
iv: iv,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
framework.blockUI();
|
||||
try {
|
||||
// Update the rights. Load without limit
|
||||
const rights = await self.model.orm.searchRead(
|
||||
"vault.right",
|
||||
[["vault_id", "=", record.resId]],
|
||||
["public_key"],
|
||||
{limit: 0}
|
||||
);
|
||||
|
||||
for (const right of rights) {
|
||||
const key = await vault.wrap_with(master_key, right.public_key);
|
||||
|
||||
changes.push({
|
||||
id: right.id,
|
||||
model: "vault.right",
|
||||
key: key,
|
||||
});
|
||||
}
|
||||
|
||||
// Re-encrypt vault.field and vault.file
|
||||
await reencrypt("vault.field", "Field");
|
||||
await reencrypt("vault.file", "File");
|
||||
|
||||
if (problems.length && !force) {
|
||||
framework.unblockUI();
|
||||
|
||||
Dialog.alert(self, "", {
|
||||
title: _lt("The following entries are broken:"),
|
||||
$content: $("<div/>").html(problems.join("<br>\n")),
|
||||
});
|
||||
}
|
||||
|
||||
if (!verify) {
|
||||
await this.rpc("/vault/replace", {data: changes});
|
||||
await this.model.root.load();
|
||||
}
|
||||
} finally {
|
||||
framework.unblockUI();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Call the right importer in the import wizard onchange of the content field
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
async _vaultImportWizard() {
|
||||
const record = this.model.root;
|
||||
if (record.resModel !== "vault.import.wizard") return;
|
||||
|
||||
// Try to import the file on the fly and store the compatible JSON in the
|
||||
// crypted_content field for the python backend
|
||||
const importer = new Importer();
|
||||
const data = await importer.import(
|
||||
await vault.unwrap(record.data.master_key),
|
||||
record.data.name,
|
||||
atob(record.data.content)
|
||||
);
|
||||
|
||||
if (data) await record.update({crypted_content: JSON.stringify(data)});
|
||||
},
|
||||
|
||||
/**
|
||||
* Ensure that a vault.right as the shared master_key set
|
||||
*
|
||||
* @private
|
||||
* @param {Object} root
|
||||
* @param {Object} right
|
||||
*/
|
||||
async _vaultEnsureRightKey(root, right) {
|
||||
if (!root.data.master_key || right.data.key) return;
|
||||
|
||||
const params = {user_id: right.data.user_id[0]};
|
||||
const user = await this.rpc("/vault/public", params);
|
||||
|
||||
if (!user || !user.public_key) throw new TypeError("User has no public key");
|
||||
|
||||
await right.update({
|
||||
key: await vault.share(root.data.master_key, user.public_key),
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Ensures that the master_key of the vault and right lines are set
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
async _vaultEnsureKeys() {
|
||||
const root = this.model.root;
|
||||
if (root.resModel !== "vault") return;
|
||||
|
||||
if (!root.data.master_key)
|
||||
await root.update({
|
||||
master_key: await vault.wrap(await utils.generate_key()),
|
||||
});
|
||||
|
||||
if (root.data.right_ids)
|
||||
for (const right of root.data.right_ids.records)
|
||||
await this._vaultEnsureRightKey(root, right);
|
||||
},
|
||||
|
||||
/**
|
||||
* Check the model of the form and call the above functions for the right case
|
||||
*
|
||||
* @private
|
||||
* @param {Object} button
|
||||
*/
|
||||
async _vaultAction(button) {
|
||||
if (!utils.supported()) {
|
||||
await this.dialogService.add(AlertDialog, {
|
||||
title: _lt("Vault is not supported"),
|
||||
body: _lt(
|
||||
"A secure browser context is required. Please switch to " +
|
||||
"https or contact your administrator"
|
||||
),
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
const root = this.model.root;
|
||||
switch (root.resModel) {
|
||||
case "res.users":
|
||||
if (button && button.name === "vault_generate_key") {
|
||||
await this._vaultRegenerateKey();
|
||||
return false;
|
||||
}
|
||||
break;
|
||||
case "vault":
|
||||
if (button && button.name === "vault_reencrypt") {
|
||||
await this._reencryptVault(false, true);
|
||||
return false;
|
||||
} else if (button && button.name === "vault_verify") {
|
||||
await this._reencryptVault(true, false);
|
||||
return false;
|
||||
}
|
||||
|
||||
await this._vaultEnsureKeys();
|
||||
break;
|
||||
|
||||
case "vault.send.wizard":
|
||||
await this._vaultSendWizard();
|
||||
break;
|
||||
|
||||
case "vault.store.wizard":
|
||||
await this._vaultStoreWizard();
|
||||
break;
|
||||
|
||||
case "vault.import.wizard":
|
||||
await this._vaultImportWizard();
|
||||
break;
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
|
||||
/**
|
||||
* Add the required rpc service to the controller which will be used to
|
||||
* get/store information from/to the vault controller
|
||||
*/
|
||||
setup() {
|
||||
if (this.props.resModel === "vault" && !utils.supported()) {
|
||||
this.props.preventCreate = true;
|
||||
this.props.preventEdit = true;
|
||||
}
|
||||
|
||||
this._super(...arguments);
|
||||
this.rpc = useService("rpc");
|
||||
},
|
||||
|
||||
/**
|
||||
* Hook into the relevant functions
|
||||
*/
|
||||
async create() {
|
||||
const _super = this._super.bind(this);
|
||||
if (this.model.root.isDirty) await this._vaultAction();
|
||||
|
||||
const ret = await _super(...arguments);
|
||||
return ret;
|
||||
},
|
||||
|
||||
async onPagerUpdate() {
|
||||
const _super = this._super.bind(this);
|
||||
if (this.model.root.isDirty) await this._vaultAction();
|
||||
return await _super(...arguments);
|
||||
},
|
||||
|
||||
async saveButtonClicked() {
|
||||
const _super = this._super.bind(this);
|
||||
if (this.model.root.isDirty) await this._vaultAction();
|
||||
return await _super(...arguments);
|
||||
},
|
||||
|
||||
async discard() {
|
||||
const _super = this._super.bind(this);
|
||||
if (this.model.root.resModel === "vault.entry")
|
||||
this.model.env.bus.trigger("RELATIONAL_MODEL:ENCRYPT_FIELDS");
|
||||
return await _super(...arguments);
|
||||
},
|
||||
|
||||
async beforeLeave() {
|
||||
const _super = this._super.bind(this);
|
||||
if (this.model.root.isDirty) await this._vaultAction();
|
||||
return await _super(...arguments);
|
||||
},
|
||||
|
||||
async beforeUnload() {
|
||||
const _super = this._super.bind(this);
|
||||
if (this.model.root.isDirty) await this._vaultAction();
|
||||
return await _super(...arguments);
|
||||
},
|
||||
|
||||
async beforeExecuteActionButton(clickParams) {
|
||||
const _super = this._super.bind(this);
|
||||
if (clickParams.special !== "cancel") {
|
||||
const _continue = await this._vaultAction(clickParams);
|
||||
if (!_continue) return false;
|
||||
}
|
||||
|
||||
return await _super(...arguments);
|
||||
},
|
||||
});
|
||||
|
||||
patch(ListController.prototype, "vault", {
|
||||
setup() {
|
||||
this._super(...arguments);
|
||||
if (this.props.resModel === "vault" && !utils.supported())
|
||||
this.props.showButtons = false;
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,128 @@
|
|||
/** @odoo-module alias=vault.export **/
|
||||
// © 2021-2024 Florian Kantelberg - initOS GmbH
|
||||
// License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
||||
|
||||
import {_lt} from "@web/core/l10n/translation";
|
||||
import utils from "vault.utils";
|
||||
|
||||
// This class handles the export to different formats by using a standardize
|
||||
// JSON formatted data generated by the python backend.
|
||||
//
|
||||
// JSON format description:
|
||||
//
|
||||
// Entries are represented as objects with the following attributes
|
||||
// `name`, `uuid`, `url`, `note`
|
||||
// Specific fields of the entry. `uuid` is used for updating existing records
|
||||
// `childs`
|
||||
// Child entries
|
||||
// `fields`, `files`
|
||||
// List of encypted fields/files with `name`, `iv`, and `value`
|
||||
//
|
||||
export default class VaultExporter {
|
||||
/**
|
||||
* Encrypt a field of the above format properly for the backend to store.
|
||||
* The changes are done inplace.
|
||||
*
|
||||
* @private
|
||||
* @param {CryptoKey} master_key
|
||||
* @param {Object} node
|
||||
*/
|
||||
async _export_json_entry(master_key, node) {
|
||||
const fields = [];
|
||||
for (const field of node.fields || [])
|
||||
fields.push({
|
||||
name: field.name,
|
||||
value: await utils.sym_decrypt(master_key, field.value, field.iv),
|
||||
});
|
||||
|
||||
const files = [];
|
||||
for (const file of node.files || [])
|
||||
files.push({
|
||||
name: file.name,
|
||||
value: await utils.sym_decrypt(master_key, file.value, file.iv),
|
||||
});
|
||||
|
||||
const childs = [];
|
||||
for (const entry of node.childs || [])
|
||||
childs.push(await this._export_json_entry(master_key, entry));
|
||||
|
||||
return {
|
||||
name: node.name || "",
|
||||
uuid: node.uuid || null,
|
||||
url: node.url || "",
|
||||
note: node.note || "",
|
||||
childs: childs,
|
||||
fields: fields,
|
||||
files: files,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt the data fro the JSON export.
|
||||
*
|
||||
* @private
|
||||
* @param {CryptoKey} master_key
|
||||
* @param {Object} data
|
||||
* @returns the encrypted entry for the database
|
||||
*/
|
||||
async _export_json_data(master_key, data) {
|
||||
const result = [];
|
||||
for (const node of data)
|
||||
result.push(await this._export_json_entry(master_key, node));
|
||||
return JSON.stringify(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Export using JSON format. The database is stored in the `data` field of the JSON
|
||||
* type and is an encrypted JSON object. For the encryption the needed encryption
|
||||
* parameter `iv`, `salt` and `iterations` are stored in the file.
|
||||
* This will add `iv` to fields and files and encrypt the `value`
|
||||
*
|
||||
* @private
|
||||
* @param {CryptoKey} master_key
|
||||
* @param {String} data
|
||||
* @returns the encrypted entry for the database
|
||||
*/
|
||||
async _export_json(master_key, data) {
|
||||
// Get the password for the exported file from the user
|
||||
const askpass = await utils.askpass(
|
||||
_lt("Please enter the password for the database")
|
||||
);
|
||||
let password = askpass.password || "";
|
||||
if (askpass.keyfile)
|
||||
password += await utils.digest(utils.toBinary(askpass.keyfile));
|
||||
|
||||
const iv = utils.generate_iv_base64();
|
||||
const salt = utils.generate_bytes(utils.SaltLength).buffer;
|
||||
const iterations = utils.Derive.iterations;
|
||||
const key = await utils.derive_key(password, salt, iterations);
|
||||
|
||||
// Unwrap the master key and decrypt the entries
|
||||
const content = await this._export_json_data(master_key, JSON.parse(data));
|
||||
return {
|
||||
type: "encrypted",
|
||||
iv: iv,
|
||||
salt: utils.toBase64(salt),
|
||||
data: await utils.sym_encrypt(key, content, iv),
|
||||
iterations: iterations,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The main export functions which checks the file ending and calls the right function
|
||||
* to handle the rest of the export
|
||||
*
|
||||
* @private
|
||||
* @param {CryptoKey} master_key
|
||||
* @param {String} filename
|
||||
* @param {String} content
|
||||
* @returns the data importable by the backend or false on error
|
||||
*/
|
||||
async export(master_key, filename, content) {
|
||||
if (!utils.supported()) return false;
|
||||
|
||||
if (filename.endsWith(".json"))
|
||||
return await this._export_json(master_key, content);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,121 @@
|
|||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<templates id="template" xml:space="preserve">
|
||||
<t t-name="vault.Field.buttons.send" owl="1">
|
||||
<button
|
||||
t-if="sendButton"
|
||||
t-on-click="_onSendValue"
|
||||
class="btn btn-secondary btn-sm fa fa-share-alt o_vault_send"
|
||||
title="Send the secret to an user"
|
||||
aria-label="Send the secret to an user"
|
||||
/>
|
||||
</t>
|
||||
|
||||
<t t-name="vault.Field.buttons" owl="1">
|
||||
<button
|
||||
t-if="!state.decrypted && showButton"
|
||||
t-on-click="_onShowValue"
|
||||
class="btn btn-secondary btn-sm fa fa-eye o_vault_show"
|
||||
title="Show"
|
||||
aria-label="Show"
|
||||
/>
|
||||
<button
|
||||
t-elif="showButton"
|
||||
t-on-click="_onShowValue"
|
||||
class="btn btn-secondary btn-sm fa fa-eye-slash o_vault_show"
|
||||
title="Hide"
|
||||
aria-label="Hide"
|
||||
/>
|
||||
<button
|
||||
t-if="copyButton"
|
||||
t-on-click="_onCopyValue"
|
||||
class="btn btn-secondary btn-sm fa fa-clipboard o_vault_clipboard"
|
||||
title="Copy to clipboard"
|
||||
aria-label="Copy to clipboard"
|
||||
/>
|
||||
<t t-call="vault.Field.buttons.send" />
|
||||
</t>
|
||||
|
||||
<t t-name="vault.FieldVault" owl="1">
|
||||
<div class="o_vault o_vault_error" t-if="!supported()">
|
||||
<span>*******</span>
|
||||
</div>
|
||||
<div class="o_vault" t-elif="props.readonly">
|
||||
<span class="o_vault_buttons">
|
||||
<t t-call="vault.Field.buttons" />
|
||||
</span>
|
||||
<span t-esc="formattedValue" t-ref="span" />
|
||||
</div>
|
||||
<div class="o_vault" t-else="">
|
||||
<span class="o_vault_buttons">
|
||||
<button
|
||||
t-if="generateButton"
|
||||
t-on-click="_onGenerateValue"
|
||||
class="btn btn-secondary btn-sm fa fa-lock o_vault_generate"
|
||||
title="Generate"
|
||||
aria-label="Generate"
|
||||
/>
|
||||
</span>
|
||||
<input class="o_input" type="text" t-esc="formattedValue" t-ref="input" />
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<t
|
||||
t-name="vault.FileVault"
|
||||
t-inherit="web.BinaryField"
|
||||
t-inherit-mode="primary"
|
||||
owl="1"
|
||||
>
|
||||
<xpath expr="//button[hasclass('o_clear_file_button')]" position="after">
|
||||
<t t-call="vault.Field.buttons.send" />
|
||||
</xpath>
|
||||
</t>
|
||||
|
||||
<t t-name="vault.FieldVaultInbox" owl="1">
|
||||
<div class="o_vault o_vault_error" t-if="!supported()">
|
||||
<span>*******</span>
|
||||
</div>
|
||||
<div class="o_vault" t-elif="props.value">
|
||||
<span class="o_vault_buttons">
|
||||
<t t-call="vault.Field.buttons" />
|
||||
<button
|
||||
t-if="saveButton"
|
||||
t-on-click="_onSaveValue"
|
||||
class="btn btn-secondary btn-sm fa fa-save"
|
||||
title="Save in a vault"
|
||||
aria-label="Save in a vault"
|
||||
/>
|
||||
</span>
|
||||
|
||||
<span class="o_vault_inbox" t-esc="formattedValue" t-ref="span" />
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<t t-name="vault.FileVaultInbox" owl="1">
|
||||
<div class="o_vault o_vault_error" t-if="!supported()">
|
||||
<span>*******</span>
|
||||
</div>
|
||||
<div class="o_vault" t-elif="props.value">
|
||||
<span class="o_vault_buttons">
|
||||
<button
|
||||
t-if="saveButton"
|
||||
t-on-click="_onSaveValue"
|
||||
class="btn btn-secondary btn-sm fa fa-save"
|
||||
title="Save in a vault"
|
||||
aria-label="Save in a vault"
|
||||
/>
|
||||
</span>
|
||||
|
||||
<a class="o_form_uri" href="#" t-on-click.prevent="onFileDownload">
|
||||
<span class="fa fa-download me-2" />
|
||||
<t t-if="state.fileName" t-esc="state.fileName" />
|
||||
</a>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<t t-name="vault.FileVaultExport" owl="1">
|
||||
<a class="o_form_uri" href="#" t-on-click.prevent="onFileDownload">
|
||||
<span class="fa fa-download me-2" />
|
||||
<t t-if="state.fileName" t-esc="state.fileName" />
|
||||
</a>
|
||||
</t>
|
||||
</templates>
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
/** @odoo-module alias=vault.export.file **/
|
||||
// © 2021-2024 Florian Kantelberg - initOS GmbH
|
||||
// License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
||||
|
||||
import {BinaryField} from "@web/views/fields/binary/binary_field";
|
||||
import Exporter from "vault.export";
|
||||
import VaultMixin from "vault.mixin";
|
||||
import {_lt} from "@web/core/l10n/translation";
|
||||
import {downloadFile} from "@web/core/network/download";
|
||||
import {registry} from "@web/core/registry";
|
||||
import utils from "vault.utils";
|
||||
|
||||
export default class VaultExportFile extends VaultMixin(BinaryField) {
|
||||
/**
|
||||
* Call the exporter and download the finalized file
|
||||
*/
|
||||
async onFileDownload() {
|
||||
if (!this.props.value) {
|
||||
this.do_warn(
|
||||
_lt("Save As..."),
|
||||
_lt("The field is empty, there's nothing to save!")
|
||||
);
|
||||
} else if (utils.supported()) {
|
||||
const exporter = new Exporter();
|
||||
const content = JSON.stringify(
|
||||
await exporter.export(
|
||||
await this._getMasterKey(),
|
||||
this.state.fileName,
|
||||
this.props.value
|
||||
)
|
||||
);
|
||||
|
||||
const buffer = new ArrayBuffer(content.length);
|
||||
const arr = new Uint8Array(buffer);
|
||||
for (let i = 0; i < content.length; i++) arr[i] = content.charCodeAt(i);
|
||||
|
||||
const blob = new Blob([arr]);
|
||||
await downloadFile(blob, this.state.fileName || "");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
VaultExportFile.template = "vault.FileVaultExport";
|
||||
|
||||
registry.category("fields").add("vault_export_file", VaultExportFile);
|
||||
|
|
@ -0,0 +1,205 @@
|
|||
/** @odoo-module alias=vault.field **/
|
||||
// © 2021-2024 Florian Kantelberg - initOS GmbH
|
||||
// License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
||||
|
||||
import {Component, useEffect, useRef, useState} from "@odoo/owl";
|
||||
import {useBus, useService} from "@web/core/utils/hooks";
|
||||
import VaultMixin from "vault.mixin";
|
||||
import {_lt} from "@web/core/l10n/translation";
|
||||
import {getActiveHotkey} from "@web/core/hotkeys/hotkey_service";
|
||||
import {registry} from "@web/core/registry";
|
||||
import utils from "vault.utils";
|
||||
|
||||
export default class VaultField extends VaultMixin(Component) {
|
||||
setup() {
|
||||
super.setup();
|
||||
|
||||
this.action = useService("action");
|
||||
this.input = useRef("input");
|
||||
this.span = useRef("span");
|
||||
this.state = useState({
|
||||
decrypted: false,
|
||||
decryptedValue: "",
|
||||
isDirty: false,
|
||||
lastSetValue: null,
|
||||
});
|
||||
|
||||
const self = this;
|
||||
useEffect(
|
||||
(inputEl) => {
|
||||
if (inputEl) {
|
||||
const onInput = self.onInput.bind(self);
|
||||
const onKeydown = self.onKeydown.bind(self);
|
||||
|
||||
inputEl.addEventListener("input", onInput);
|
||||
inputEl.addEventListener("keydown", onKeydown);
|
||||
return () => {
|
||||
inputEl.removeEventListener("input", onInput);
|
||||
inputEl.removeEventListener("keydown", onKeydown);
|
||||
};
|
||||
}
|
||||
},
|
||||
() => [self.input.el]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const isInvalid = self.props.record
|
||||
? self.props.record.isInvalid(self.props.name)
|
||||
: false;
|
||||
|
||||
if (self.input.el && !self.state.isDirty && !isInvalid) {
|
||||
Promise.resolve(self.getValue()).then((val) => {
|
||||
if (!self.input.el) return;
|
||||
|
||||
if (val) self.input.el.value = val;
|
||||
else if (val !== "")
|
||||
self.props.record.setInvalidField(self.props.name);
|
||||
});
|
||||
self.state.lastSetValue = self.input.el.value;
|
||||
}
|
||||
});
|
||||
|
||||
useBus(self.env.bus, "RELATIONAL_MODEL:WILL_SAVE_URGENTLY", () =>
|
||||
self.commitChanges(true)
|
||||
);
|
||||
useBus(self.env.bus, "RELATIONAL_MODEL:NEED_LOCAL_CHANGES", (ev) =>
|
||||
ev.detail.proms.push(self.commitChanges())
|
||||
);
|
||||
useBus(self.env.bus, "RELATIONAL_MODEL:ENCRYPT_FIELDS", () => {
|
||||
this.state.decrypted = false;
|
||||
this.showValue();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a dialog to generate a new secret
|
||||
*
|
||||
* @param {Object} ev
|
||||
*/
|
||||
async _onGenerateValue(ev) {
|
||||
ev.stopPropagation();
|
||||
|
||||
const password = await utils.generate_pass();
|
||||
await this.storeValue(password);
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle between visible and invisible secret
|
||||
*
|
||||
* @param {Object} ev
|
||||
*/
|
||||
async _onShowValue(ev) {
|
||||
ev.stopPropagation();
|
||||
|
||||
this.state.decrypted = !this.state.decrypted;
|
||||
if (this.state.decrypted) {
|
||||
this.state.decryptedValue = await this._decrypt(this.props.value);
|
||||
} else {
|
||||
this.state.decryptedValue = "";
|
||||
}
|
||||
|
||||
await this.showValue();
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy the decrypted secret to the clipboard
|
||||
*
|
||||
* @param {Object} ev
|
||||
*/
|
||||
async _onCopyValue(ev) {
|
||||
ev.stopPropagation();
|
||||
|
||||
const value = await this._decrypt(this.props.value);
|
||||
await navigator.clipboard.writeText(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send the secret to an inbox of an user
|
||||
*
|
||||
* @param {Object} ev
|
||||
*/
|
||||
async _onSendValue(ev) {
|
||||
ev.stopPropagation();
|
||||
|
||||
await this.sendValue(this.props.value, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the decrypted value or a placeholder
|
||||
*
|
||||
* @returns the decrypted value or a placeholder
|
||||
*/
|
||||
get formattedValue() {
|
||||
if (!this.props.value) return "";
|
||||
if (this.state.decrypted) return this.state.decryptedValue || "*******";
|
||||
return "*******";
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt the value of the field
|
||||
*
|
||||
* @returns decrypted value
|
||||
*/
|
||||
async getValue() {
|
||||
return await this._decrypt(this.props.value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the value shown
|
||||
*/
|
||||
async showValue() {
|
||||
this.span.el.innerHTML = this.formattedValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle input event and set the state to dirty
|
||||
*
|
||||
* @param {Object} ev
|
||||
*/
|
||||
onInput(ev) {
|
||||
ev.stopPropagation();
|
||||
|
||||
this.state.isDirty = ev.target.value !== this.lastSetValue;
|
||||
if (this.props.setDirty) this.props.setDirty(this.state.isDirty);
|
||||
}
|
||||
|
||||
/**
|
||||
* Commit the changes of the input field to the record
|
||||
*
|
||||
* @param {Boolean} urgent
|
||||
*/
|
||||
async commitChanges(urgent) {
|
||||
if (!this.input.el) return;
|
||||
|
||||
this.state.isDirty = this.input.el.value !== this.lastSetValue;
|
||||
if (this.state.isDirty || urgent) {
|
||||
this.state.isDirty = false;
|
||||
|
||||
const val = this.input.el.value || false;
|
||||
if (val !== (this.state.lastSetValue || false)) {
|
||||
this.state.lastSetValue = this.input.el.value;
|
||||
this.state.decryptedValue = this.input.el.value;
|
||||
await this.storeValue(val);
|
||||
this.props.setDirty(this.state.isDirty);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle keyboard events and trigger changes
|
||||
*
|
||||
* @param {Object} ev
|
||||
*/
|
||||
onKeydown(ev) {
|
||||
ev.stopPropagation();
|
||||
|
||||
const hotkey = getActiveHotkey(ev);
|
||||
if (["enter", "tab", "shift+tab"].includes(hotkey)) this.commitChanges(false);
|
||||
}
|
||||
}
|
||||
|
||||
VaultField.displayName = _lt("Vault Field");
|
||||
VaultField.supportedTypes = ["char"];
|
||||
VaultField.template = "vault.FieldVault";
|
||||
|
||||
registry.category("fields").add("vault_field", VaultField);
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
/** @odoo-module alias=vault.file **/
|
||||
// © 2021-2024 Florian Kantelberg - initOS GmbH
|
||||
// License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
||||
|
||||
import {BinaryField} from "@web/views/fields/binary/binary_field";
|
||||
import VaultMixin from "vault.mixin";
|
||||
import {_lt} from "@web/core/l10n/translation";
|
||||
import {downloadFile} from "@web/core/network/download";
|
||||
import {registry} from "@web/core/registry";
|
||||
import {useService} from "@web/core/utils/hooks";
|
||||
import utils from "vault.utils";
|
||||
|
||||
export default class VaultFile extends VaultMixin(BinaryField) {
|
||||
setup() {
|
||||
super.setup();
|
||||
|
||||
this.action = useService("action");
|
||||
}
|
||||
|
||||
async update({data, name}) {
|
||||
const encrypted = await this._encrypt(data);
|
||||
return await super.update({data: encrypted, name: name});
|
||||
}
|
||||
|
||||
/**
|
||||
* Send the secret to an inbox of an user
|
||||
*
|
||||
* @param {Object} ev
|
||||
*/
|
||||
async _onSendValue(ev) {
|
||||
ev.stopPropagation();
|
||||
|
||||
await this.sendValue("", this.props.value, this.state.fileName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt the file and download it
|
||||
*/
|
||||
async onFileDownload() {
|
||||
if (!this.props.value) {
|
||||
this.do_warn(
|
||||
_lt("Save As..."),
|
||||
_lt("The field is empty, there's nothing to save!")
|
||||
);
|
||||
} else if (utils.supported()) {
|
||||
const decrypted = await this._decrypt(this.props.value);
|
||||
const base64 = atob(decrypted);
|
||||
const buffer = new ArrayBuffer(base64.length);
|
||||
const arr = new Uint8Array(buffer);
|
||||
for (let i = 0; i < base64.length; i++) arr[i] = base64.charCodeAt(i);
|
||||
|
||||
const blob = new Blob([arr]);
|
||||
await downloadFile(blob, this.state.fileName || "");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
VaultFile.displayName = _lt("Vault File");
|
||||
VaultFile.template = "vault.FileVault";
|
||||
|
||||
registry.category("fields").add("vault_file", VaultFile);
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
/** @odoo-module alias=vault.inbox.field **/
|
||||
// © 2021-2024 Florian Kantelberg - initOS GmbH
|
||||
// License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
||||
|
||||
import VaultField from "vault.field";
|
||||
import VaultInboxMixin from "vault.inbox.mixin";
|
||||
import {_lt} from "@web/core/l10n/translation";
|
||||
import {registry} from "@web/core/registry";
|
||||
import utils from "vault.utils";
|
||||
import vault from "vault";
|
||||
|
||||
export default class VaultInboxField extends VaultInboxMixin(VaultField) {
|
||||
/**
|
||||
* Save the content in an entry of a vault
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
async _onSaveValue() {
|
||||
await this.saveValue("vault.field", this.props.value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt the data with the private key of the vault
|
||||
*
|
||||
* @private
|
||||
* @param {String} data
|
||||
* @returns the decrypted data
|
||||
*/
|
||||
async _decrypt(data) {
|
||||
if (!utils.supported()) return null;
|
||||
|
||||
const iv = this.props.record.data[this.props.fieldIV];
|
||||
const wrapped_key = this.props.record.data[this.props.fieldKey];
|
||||
|
||||
if (!iv || !wrapped_key) return false;
|
||||
|
||||
const key = await vault.unwrap(wrapped_key);
|
||||
return await utils.sym_decrypt(key, data, iv);
|
||||
}
|
||||
}
|
||||
|
||||
VaultInboxField.defaultProps = {
|
||||
...VaultField.defaultProps,
|
||||
fieldKey: "key",
|
||||
};
|
||||
VaultInboxField.displayName = _lt("Vault Inbox Field");
|
||||
VaultInboxField.template = "vault.FieldVaultInbox";
|
||||
|
||||
registry.category("fields").add("vault_inbox_field", VaultInboxField);
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
/** @odoo-module alias=vault.inbox.file **/
|
||||
// © 2021-2024 Florian Kantelberg - initOS GmbH
|
||||
// License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
||||
|
||||
import VaultFile from "vault.file";
|
||||
import VaultInboxMixin from "vault.inbox.mixin";
|
||||
import {_lt} from "@web/core/l10n/translation";
|
||||
import {registry} from "@web/core/registry";
|
||||
import utils from "vault.utils";
|
||||
import vault from "vault";
|
||||
|
||||
export default class VaultInboxFile extends VaultInboxMixin(VaultFile) {
|
||||
/**
|
||||
* Save the content in an entry of a vault
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
async _onSaveValue() {
|
||||
await this.saveValue("vault.file", this.props.value, this.state.fileName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt the data with the private key of the vault
|
||||
*
|
||||
* @private
|
||||
* @param {String} data
|
||||
* @returns the decrypted data
|
||||
*/
|
||||
async _decrypt(data) {
|
||||
if (!utils.supported()) return null;
|
||||
|
||||
const iv = this.props.record.data[this.props.fieldIV];
|
||||
const wrapped_key = this.props.record.data[this.props.fieldKey];
|
||||
|
||||
if (!iv || !wrapped_key) return false;
|
||||
|
||||
const key = await vault.unwrap(wrapped_key);
|
||||
return await utils.sym_decrypt(key, data, iv);
|
||||
}
|
||||
}
|
||||
|
||||
VaultInboxFile.defaultProps = {
|
||||
...VaultFile.defaultProps,
|
||||
fieldKey: "key",
|
||||
};
|
||||
VaultInboxFile.displayName = _lt("Vault Inbox File");
|
||||
VaultInboxFile.template = "vault.FileVaultInbox";
|
||||
|
||||
registry.category("fields").add("vault_inbox_file", VaultInboxFile);
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
/** @odoo-module alias=vault.inbox.mixin **/
|
||||
// © 2021-2024 Florian Kantelberg - initOS GmbH
|
||||
// License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
||||
|
||||
import {_lt} from "@web/core/l10n/translation";
|
||||
import {useService} from "@web/core/utils/hooks";
|
||||
import utils from "vault.utils";
|
||||
import vault from "vault";
|
||||
|
||||
export default (x) => {
|
||||
class Extended extends x {
|
||||
setup() {
|
||||
super.setup();
|
||||
|
||||
if (!this.action) this.action = useService("action");
|
||||
}
|
||||
|
||||
/**
|
||||
* Save the content in an entry of a vault
|
||||
*
|
||||
* @private
|
||||
* @param {String} model
|
||||
* @param {String} value
|
||||
* @param {String} name
|
||||
*/
|
||||
async saveValue(model, value, name = "") {
|
||||
const key = await utils.generate_key();
|
||||
const iv = utils.generate_iv_base64();
|
||||
const decrypted = await this._decrypt(value);
|
||||
|
||||
this.action.doAction({
|
||||
type: "ir.actions.act_window",
|
||||
title: _lt("Store the secret in a vault"),
|
||||
target: "new",
|
||||
res_model: "vault.store.wizard",
|
||||
views: [[false, "form"]],
|
||||
context: {
|
||||
default_model: model,
|
||||
default_secret_temporary: await utils.sym_encrypt(
|
||||
key,
|
||||
decrypted,
|
||||
iv
|
||||
),
|
||||
default_name: name,
|
||||
default_iv: iv,
|
||||
default_key: await vault.wrap(key),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Extended.props = {
|
||||
...x.props,
|
||||
storeModel: {type: String, optional: true},
|
||||
};
|
||||
|
||||
Extended.extractProps = ({attrs}) => {
|
||||
return {
|
||||
storeModel: attrs.store,
|
||||
};
|
||||
};
|
||||
|
||||
return Extended;
|
||||
};
|
||||
|
|
@ -0,0 +1,197 @@
|
|||
/** @odoo-module alias=vault.mixin **/
|
||||
// © 2021-2024 Florian Kantelberg - initOS GmbH
|
||||
// License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
||||
|
||||
import {_lt} from "@web/core/l10n/translation";
|
||||
import {standardFieldProps} from "@web/views/fields/standard_field_props";
|
||||
import utils from "vault.utils";
|
||||
import vault from "vault";
|
||||
|
||||
export default (x) => {
|
||||
class Extended extends x {
|
||||
supported() {
|
||||
return utils.supported();
|
||||
}
|
||||
|
||||
// Control the visibility of the buttons
|
||||
get showButton() {
|
||||
return this.props.value;
|
||||
}
|
||||
get copyButton() {
|
||||
return this.props.value;
|
||||
}
|
||||
get sendButton() {
|
||||
return this.props.value;
|
||||
}
|
||||
get saveButton() {
|
||||
return this.props.value;
|
||||
}
|
||||
get generateButton() {
|
||||
return true;
|
||||
}
|
||||
get isNew() {
|
||||
return Boolean(this.model.record.isNew);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the value by encrypting it
|
||||
*
|
||||
* @param {String} value
|
||||
* @param {Object} options
|
||||
*/
|
||||
async storeValue(value, options) {
|
||||
if (!utils.supported()) return;
|
||||
|
||||
const encrypted = await this._encrypt(value);
|
||||
await this.props.update(encrypted, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send the value to an inbox
|
||||
*
|
||||
* @param {String} value_field
|
||||
* @param {String} value_file
|
||||
* @param {String} filename
|
||||
*/
|
||||
async sendValue(value_field = "", value_file = "", filename = "") {
|
||||
if (!utils.supported()) return;
|
||||
|
||||
if (!value_field && !value_file) return;
|
||||
|
||||
let enc_field = false,
|
||||
enc_file = false;
|
||||
|
||||
// Prepare the key and iv for the reencryption
|
||||
const key = await utils.generate_key();
|
||||
const iv = utils.generate_iv_base64();
|
||||
|
||||
// Reencrypt the field
|
||||
if (value_field) {
|
||||
const decrypted = await this._decrypt(value_field);
|
||||
enc_field = await utils.sym_encrypt(key, decrypted, iv);
|
||||
}
|
||||
|
||||
// Reencrypt the file
|
||||
if (value_file) {
|
||||
const decrypted = await this._decrypt(value_file);
|
||||
enc_file = await utils.sym_encrypt(key, decrypted, iv);
|
||||
}
|
||||
|
||||
// Call the wizard to handle the user selection and storage
|
||||
this.action.doAction({
|
||||
type: "ir.actions.act_window",
|
||||
title: _lt("Send the secret to another user"),
|
||||
target: "new",
|
||||
res_model: "vault.send.wizard",
|
||||
views: [[false, "form"]],
|
||||
context: {
|
||||
default_secret: enc_field,
|
||||
default_secret_file: enc_file,
|
||||
default_filename: filename || false,
|
||||
default_iv: iv,
|
||||
default_key: await vault.wrap(key),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the value of a different field
|
||||
*
|
||||
* @param {String} field
|
||||
* @param {String} value
|
||||
*/
|
||||
async _setFieldValue(field, value) {
|
||||
this.props.record.update({[field]: value});
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the IV or generate a new one if needed
|
||||
*
|
||||
* @returns the IV to use
|
||||
*/
|
||||
async _getIV() {
|
||||
if (!utils.supported()) return null;
|
||||
|
||||
// Read the IV from the field
|
||||
let iv = this.props.record.data[this.props.fieldIV];
|
||||
if (iv) return iv;
|
||||
|
||||
// Generate a new IV
|
||||
iv = utils.generate_iv_base64();
|
||||
await this._setFieldValue(this.props.fieldIV, iv);
|
||||
return iv;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the master key of the vault or generate a new one
|
||||
*
|
||||
* @returns the master key to use
|
||||
*/
|
||||
async _getMasterKey() {
|
||||
if (!utils.supported()) return null;
|
||||
|
||||
// Check if the master key is already extracted
|
||||
if (this.key) return await vault.unwrap(this.key);
|
||||
|
||||
// Get the wrapped master key from the field
|
||||
this.key = this.props.record.data[this.props.fieldKey];
|
||||
if (this.key) return await vault.unwrap(this.key);
|
||||
|
||||
// Generate a new master key and write it to the field
|
||||
const key = await utils.generate_key();
|
||||
this.key = await vault.wrap(key);
|
||||
await this._setFieldValue(this.props.fieldKey, this.key);
|
||||
return key;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt data with the master key stored in the vault
|
||||
*
|
||||
* @param {String} data
|
||||
* @returns the decrypted data
|
||||
*/
|
||||
async _decrypt(data) {
|
||||
if (!utils.supported()) return null;
|
||||
|
||||
const iv = await this._getIV();
|
||||
const key = await this._getMasterKey();
|
||||
return await utils.sym_decrypt(key, data, iv);
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt data with the master key stored in the vault
|
||||
*
|
||||
* @param {String} data
|
||||
* @returns the encrypted data
|
||||
*/
|
||||
async _encrypt(data) {
|
||||
if (!utils.supported()) return null;
|
||||
|
||||
const iv = await this._getIV();
|
||||
const key = await this._getMasterKey();
|
||||
return await utils.sym_encrypt(key, data, iv);
|
||||
}
|
||||
}
|
||||
|
||||
Extended.defaultProps = {
|
||||
...x.defaultProps,
|
||||
fieldIV: "iv",
|
||||
fieldKey: "master_key",
|
||||
};
|
||||
Extended.props = {
|
||||
...standardFieldProps,
|
||||
...x.props,
|
||||
fieldKey: {type: String, optional: true},
|
||||
fieldIV: {type: String, optional: true},
|
||||
};
|
||||
Extended.extractProps = ({attrs, field}) => {
|
||||
const extract_props = x.extractProps || (() => ({}));
|
||||
return {
|
||||
...extract_props({attrs, field}),
|
||||
fieldKey: attrs.key,
|
||||
fieldIV: attrs.iv,
|
||||
};
|
||||
};
|
||||
|
||||
return Extended;
|
||||
};
|
||||
|
|
@ -0,0 +1,244 @@
|
|||
/** @odoo-module alias=vault.import **/
|
||||
// © 2021-2024 Florian Kantelberg - initOS GmbH
|
||||
// License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
||||
|
||||
/* global kdbxweb */
|
||||
|
||||
import {_t} from "web.core";
|
||||
import framework from "web.framework";
|
||||
import utils from "vault.utils";
|
||||
|
||||
async function encrypted_field(master_key, name, value) {
|
||||
if (!value) return null;
|
||||
|
||||
const iv = utils.generate_iv_base64();
|
||||
return {
|
||||
name: name,
|
||||
iv: iv,
|
||||
value: await utils.sym_encrypt(master_key, value, iv),
|
||||
};
|
||||
}
|
||||
|
||||
// This class handles the import from different formats by returning
|
||||
// an importable JSON formatted data which will be handled by the python
|
||||
// backend.
|
||||
//
|
||||
// JSON format description:
|
||||
//
|
||||
// Entries are represented as objects with the following attributes
|
||||
// `name`, `uuid`, `url`, `note`
|
||||
// Specific fields of the entry. `uuid` is used for updating existing records
|
||||
// `childs`
|
||||
// Child entries
|
||||
// `fields`, `files`
|
||||
// List of encypted fields/files with `name`, `iv`, and `value`
|
||||
//
|
||||
export default class VaultImporter {
|
||||
/**
|
||||
* Encrypt a field of the above format properly for the backend to store.
|
||||
* The changes are done inplace.
|
||||
*
|
||||
* @private
|
||||
* @param {CryptoKey} master_key
|
||||
* @param {Object} node
|
||||
*/
|
||||
async _import_json_entry(master_key, node) {
|
||||
for (const field of node.fields || []) {
|
||||
field.iv = utils.generate_iv_base64();
|
||||
field.value = await utils.sym_encrypt(master_key, field.value, field.iv);
|
||||
}
|
||||
|
||||
for (const file of node.files || []) {
|
||||
file.iv = utils.generate_iv_base64();
|
||||
file.value = await utils.sym_encrypt(master_key, file.value, file.iv);
|
||||
}
|
||||
|
||||
for (const entry of node.childs || [])
|
||||
await this._import_json_entry(master_key, entry);
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt the data from the JSON import. This will add `iv` to fields and files
|
||||
* and encrypt the `value`
|
||||
*
|
||||
* @private
|
||||
* @param {CryptoKey} master_key
|
||||
* @param {String} data
|
||||
* @returns the encrypted entry for the database
|
||||
*/
|
||||
async _import_json_data(master_key, data) {
|
||||
for (const node of data) await this._import_json_entry(master_key, node);
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load from an encrypted JSON file. Encrypt the data with similar format as
|
||||
* described above. This will add `iv` to fields and files and encrypt the `value`
|
||||
*
|
||||
* @private
|
||||
* @param {CryptoKey} master_key
|
||||
* @param {Object} content
|
||||
* @returns the encrypted entry for the database
|
||||
*/
|
||||
async _import_encrypted_json(master_key, content) {
|
||||
const askpass = await utils.askpass(
|
||||
_t("Please enter the password for the database")
|
||||
);
|
||||
let password = askpass.password || "";
|
||||
if (askpass.keyfile)
|
||||
password += await utils.digest(utils.toBinary(askpass.keyfile));
|
||||
|
||||
const key = await utils.derive_key(
|
||||
password,
|
||||
utils.fromBase64(content.salt),
|
||||
content.iterations
|
||||
);
|
||||
const result = await utils.sym_decrypt(key, content.data, content.iv);
|
||||
return await this._import_json_data(master_key, JSON.parse(result));
|
||||
}
|
||||
|
||||
/**
|
||||
* Import using JSON format. The database is stored in the `data` field of the JSON
|
||||
* type and is either a JSON object or an encrypted JSON object. For the encryption
|
||||
* the needed encryption parameter `iv`, `salt` and `iterations` are stored in the
|
||||
* file. This will add `iv` to fields and files and encrypt the `value`
|
||||
*
|
||||
* @private
|
||||
* @param {CryptoKey} master_key
|
||||
* @param {String} data
|
||||
* @returns the encrypted entry for the database
|
||||
*/
|
||||
async _import_json(master_key, data) {
|
||||
// Unwrap the master key and encrypt the entries
|
||||
const result = JSON.parse(data);
|
||||
switch (result.type) {
|
||||
case "encrypted":
|
||||
return await this._import_encrypted_json(master_key, result);
|
||||
case "raw":
|
||||
return await this._import_json_data(master_key, result.data);
|
||||
}
|
||||
|
||||
throw Error(_t("Unsupported file to import"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt an entry from the kdbx file properly for the backend to store
|
||||
*
|
||||
* @private
|
||||
* @param {CryptoKey} master_key
|
||||
* @param {Object} entry
|
||||
* @returns the encrypted entry for the database
|
||||
*/
|
||||
async _import_kdbx_entry(master_key, entry) {
|
||||
let pass = entry.fields.Password;
|
||||
if (pass) pass = pass.getText();
|
||||
|
||||
const res = {
|
||||
uuid: entry.uuid && entry.uuid.id,
|
||||
note: entry.fields.Notes,
|
||||
name: entry.fields.Title,
|
||||
url: entry.fields.URL,
|
||||
fields: [
|
||||
await encrypted_field(master_key, "Username", entry.fields.UserName),
|
||||
await encrypted_field(master_key, "Password", pass),
|
||||
],
|
||||
files: [],
|
||||
};
|
||||
|
||||
for (const name in entry.binaries)
|
||||
res.files.push(
|
||||
await encrypted_field(
|
||||
master_key,
|
||||
name,
|
||||
utils.toBase64(entry.binaries[name].value)
|
||||
)
|
||||
);
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a kdbx group entry by creating an sub-entry and calling the right functions
|
||||
* on the childs
|
||||
*
|
||||
* @private
|
||||
* @param {CryptoKey} master_key
|
||||
* @param {Object} group
|
||||
* @returns the encrypted entry for the database
|
||||
*/
|
||||
async _import_kdbx_group(master_key, group) {
|
||||
const res = {
|
||||
uuid: group.uuid && group.uuid.id,
|
||||
name: group.name,
|
||||
note: group.notes,
|
||||
childs: [],
|
||||
};
|
||||
|
||||
for (const sub_group of group.groups || [])
|
||||
res.childs.push(await this._import_kdbx_group(master_key, sub_group));
|
||||
|
||||
for (const entry of group.entries || [])
|
||||
res.childs.push(await this._import_kdbx_entry(master_key, entry));
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a kdbx file, encrypt the data, and return in the described JSON format
|
||||
*
|
||||
* @private
|
||||
* @param {CryptoKey} master_key
|
||||
* @param {String} data
|
||||
* @returns the encrypted data for the backend
|
||||
*/
|
||||
async _import_kdbx(master_key, data) {
|
||||
// Get the credentials of the keepass database
|
||||
const askpass = await utils.askpass(
|
||||
_t("Please enter the password for the keepass database")
|
||||
);
|
||||
|
||||
// TODO: challenge-response
|
||||
const credentials = new kdbxweb.Credentials(
|
||||
(askpass.password && kdbxweb.ProtectedValue.fromString(askpass.password)) ||
|
||||
null,
|
||||
askpass.keyfile || null
|
||||
);
|
||||
|
||||
// Convert the data to an ArrayBuffer
|
||||
const buffer = utils.fromBinary(data);
|
||||
|
||||
// Decrypt the database
|
||||
const db = await kdbxweb.Kdbx.load(buffer, credentials);
|
||||
|
||||
try {
|
||||
// Unwrap the master key, format, and encrypt the database
|
||||
framework.blockUI();
|
||||
const result = [];
|
||||
for (const group of db.groups)
|
||||
result.push(await this._import_kdbx_group(master_key, group));
|
||||
return result;
|
||||
} finally {
|
||||
framework.unblockUI();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The main import functions which checks the file ending and calls the right function
|
||||
* to handle the rest of the import
|
||||
*
|
||||
* @private
|
||||
* @param {CryptoKey} master_key
|
||||
* @param {String} filename
|
||||
* @param {String} content
|
||||
* @returns the data importable by the backend or false on error
|
||||
*/
|
||||
async import(master_key, filename, content) {
|
||||
if (!utils.supported()) return false;
|
||||
|
||||
if (filename.endsWith(".json"))
|
||||
return await this._import_json(master_key, content);
|
||||
else if (filename.endsWith(".kdbx"))
|
||||
return await this._import_kdbx(master_key, content);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import {ListRenderer} from "@web/views/list/list_renderer";
|
||||
import {patch} from "@web/core/utils/patch";
|
||||
|
||||
patch(ListRenderer.prototype, "vault", {
|
||||
getCellTitle(column) {
|
||||
const _super = this._super.bind(this);
|
||||
const attrs = column.rawAttrs || {};
|
||||
if (attrs.widget !== "vault_field") return _super(...arguments);
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,100 @@
|
|||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<templates id="template" xml:space="preserve">
|
||||
<div t-name="vault.askpass" class="o_form_view">
|
||||
<label for="password" class="col-lg-auto col-form-label">
|
||||
Please enter your password or upload a keyfile:
|
||||
</label>
|
||||
|
||||
<table class="col o_group">
|
||||
<tr>
|
||||
<td class="o_td_label text-nowrap">
|
||||
<label class="o_form_label">Enter your password:</label>
|
||||
</td>
|
||||
<td class="col-12">
|
||||
<input
|
||||
type="password"
|
||||
name="password"
|
||||
id="password"
|
||||
required="required"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr t-if="confirm">
|
||||
<td class="o_td_label text-nowrap">
|
||||
<label class="o_form_label">Confirm your password:</label>
|
||||
</td>
|
||||
<td class="col-12">
|
||||
<input
|
||||
type="password"
|
||||
name="confirm"
|
||||
id="confirm"
|
||||
required="required"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="o_td_label text-nowrap">
|
||||
<label class="o_form_label">Keyfile:</label>
|
||||
</td>
|
||||
<td class="col-12">
|
||||
<input
|
||||
type="file"
|
||||
name="keyfile"
|
||||
id="keyfile"
|
||||
required="required"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div t-name="vault.generate_pass" class="o_form_view">
|
||||
<label for="password" class="col-lg-auto col-form-label">
|
||||
Generate a new secret:
|
||||
</label>
|
||||
|
||||
<table class="col o_group">
|
||||
<tr>
|
||||
<td class="o_td_label">
|
||||
<label class="o_form_label">Password:</label>
|
||||
</td>
|
||||
<td class="col-12">
|
||||
<span id="password" class="col-12 text-monospace" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="o_td_label">
|
||||
<label class="o_form_label">Length:</label>
|
||||
</td>
|
||||
<td class="col-12">
|
||||
<input
|
||||
type="range"
|
||||
id="length"
|
||||
min="8"
|
||||
max="64"
|
||||
value="15"
|
||||
class="col-12 custom-range align-middle"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="o_td_label">
|
||||
<label class="o_form_label">Characters:</label>
|
||||
</td>
|
||||
<td class="col-12">
|
||||
<input type="checkbox" id="big_letter" checked="checked" />
|
||||
<label class="o_form_label">A-Z</label>
|
||||
|
||||
<input type="checkbox" id="small_letter" checked="checked" />
|
||||
<label class="o_form_label">a-z</label>
|
||||
|
||||
<input type="checkbox" id="digits" checked="checked" />
|
||||
<label class="o_form_label">0-9</label>
|
||||
|
||||
<input type="checkbox" id="special" />
|
||||
<label class="o_form_label">Special</label>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</templates>
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
/** @odoo-module **/
|
||||
// © 2021-2022 Florian Kantelberg - initOS GmbH
|
||||
// License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
||||
|
||||
import {registry} from "@web/core/registry";
|
||||
|
||||
export function vaultPreferencesItem(env) {
|
||||
return {
|
||||
type: "item",
|
||||
id: "key_management",
|
||||
description: env._t("Key Management"),
|
||||
callback: async function () {
|
||||
const actionDescription = await env.services.orm.call(
|
||||
"res.users",
|
||||
"action_get_vault"
|
||||
);
|
||||
actionDescription.res_id = env.services.user.userId;
|
||||
env.services.action.doAction(actionDescription);
|
||||
},
|
||||
sequence: 55,
|
||||
};
|
||||
}
|
||||
|
||||
registry.category("user_menuitems").add("key", vaultPreferencesItem, {force: true});
|
||||
|
|
@ -0,0 +1,426 @@
|
|||
/** @odoo-module alias=vault **/
|
||||
// © 2021-2024 Florian Kantelberg - initOS GmbH
|
||||
// License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
||||
|
||||
import {_t} from "web.core";
|
||||
import ajax from "web.ajax";
|
||||
import {session} from "@web/session";
|
||||
import utils from "vault.utils";
|
||||
|
||||
// Database name on the browser
|
||||
const Database = "vault";
|
||||
|
||||
const indexedDB =
|
||||
window.indexedDB ||
|
||||
window.mozIndexedDB ||
|
||||
window.webkitIndexedDB ||
|
||||
window.msIndexedDB ||
|
||||
window.shimIndexedDB;
|
||||
|
||||
// Expiration time of the vault store entries
|
||||
const Expiration = 15 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Ask the user to enter a password using a dialog and put the password together
|
||||
*
|
||||
* @param {Boolean} confirm
|
||||
* @returns password
|
||||
*/
|
||||
async function askpassword(confirm = false) {
|
||||
const askpass = await utils.askpass(
|
||||
_t("Please enter the password for your private key"),
|
||||
{confirm: confirm}
|
||||
);
|
||||
|
||||
let password = askpass.password || "";
|
||||
if (askpass.keyfile)
|
||||
password += await utils.digest(utils.toBinary(askpass.keyfile));
|
||||
|
||||
return password;
|
||||
}
|
||||
|
||||
// Vault implementation
|
||||
class Vault {
|
||||
/**
|
||||
* Check if the user actually has keys otherwise generate them on init
|
||||
*
|
||||
* @override
|
||||
*/
|
||||
constructor() {
|
||||
const self = this;
|
||||
|
||||
function waitAndCheck() {
|
||||
if (!utils.supported()) return null;
|
||||
|
||||
if (odoo.isReady) self._initialize_keys();
|
||||
else setTimeout(waitAndCheck, 500);
|
||||
}
|
||||
|
||||
setTimeout(waitAndCheck, 500);
|
||||
}
|
||||
|
||||
/**
|
||||
* RPC call to the backend
|
||||
*
|
||||
* @param {String} url
|
||||
* @param {Object} params
|
||||
* @param {Object} options
|
||||
* @returns promise
|
||||
*/
|
||||
rpc(url, params, options) {
|
||||
return ajax.jsonRpc(url, "call", params, _.clone(options || {}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a new key pair and export to database and object store
|
||||
*/
|
||||
async generate_keys() {
|
||||
this.keys = await utils.generate_key_pair();
|
||||
this.time = new Date();
|
||||
|
||||
if (!(await this._export_to_database()))
|
||||
throw Error(_t("Failed to export the keys to the database"));
|
||||
|
||||
await this._export_to_store();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if export to database is required due to key migration
|
||||
*
|
||||
* @private
|
||||
* @param {String} password
|
||||
*/
|
||||
async _check_key_migration(password = null) {
|
||||
if (!this.version) await this._export_to_database(password);
|
||||
if (this.iterations < utils.Derive.iterations)
|
||||
await this._export_to_database(password);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lazy initialization of the keys which is not fully loading the keys
|
||||
* into the javascript but ensures that keys exist in the database to
|
||||
* to be loaded
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
async _initialize_keys() {
|
||||
// Get the uuid of the currently active keys from the database
|
||||
this.uuid = await this._check_database();
|
||||
if (this.uuid) {
|
||||
// If the object store has the keys it's done
|
||||
if (await this._import_from_store()) return;
|
||||
|
||||
// Otherwise an import from the database and export to the object store
|
||||
// is needed
|
||||
if (await this._import_from_database()) {
|
||||
await this._export_to_store();
|
||||
return true;
|
||||
}
|
||||
|
||||
// This should be silent because it would influence the entire workflow
|
||||
console.error("Failed to import the keys from the database");
|
||||
return false;
|
||||
}
|
||||
|
||||
// There are no keys in the database which means we have to generate them
|
||||
return await this.generate_keys();
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure that the keys are available
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
async _ensure_keys() {
|
||||
// If the object store has the keys it's done
|
||||
if (this.uuid && !this.time) await this._import_from_store();
|
||||
|
||||
// Check if the keys expired
|
||||
const now = new Date();
|
||||
if (this.time && now - this.time <= Expiration) return;
|
||||
|
||||
// Keys expired means that we have to get them again
|
||||
this.keys = this.time = null;
|
||||
|
||||
// Clear the object store first
|
||||
const store = await this._get_object_store();
|
||||
store.clear();
|
||||
|
||||
// Import the keys from the database
|
||||
if (!(await this._import_from_database()))
|
||||
throw Error(_t("Failed to import keys from database"));
|
||||
|
||||
// Store the imported keys in the object store for the next calls
|
||||
if (!(await this._export_to_store()))
|
||||
throw Error(_t("Failed to export keys to object store"));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the private key and check if the keys expired
|
||||
*
|
||||
* @returns the private key of the user
|
||||
*/
|
||||
async get_private_key() {
|
||||
await this._ensure_keys();
|
||||
return this.keys.privateKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the public key and check if the keys expired
|
||||
*
|
||||
* @returns the public key of the user
|
||||
*/
|
||||
async get_public_key() {
|
||||
await this._ensure_keys();
|
||||
return this.keys.publicKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the indexed DB and return object store using promise
|
||||
*
|
||||
* @private
|
||||
* @returns a promise
|
||||
*/
|
||||
_get_object_store() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const open = indexedDB.open(Database, 1);
|
||||
open.onupgradeneeded = function () {
|
||||
const db = open.result;
|
||||
db.createObjectStore(Database, {keyPath: "id"});
|
||||
};
|
||||
|
||||
open.onerror = function (event) {
|
||||
reject(`error opening database ${event.target.errorCode}`);
|
||||
};
|
||||
|
||||
open.onsuccess = function () {
|
||||
const db = open.result;
|
||||
const tx = db.transaction(Database, "readwrite");
|
||||
|
||||
resolve(tx.objectStore(Database));
|
||||
|
||||
tx.oncomplete = function () {
|
||||
db.close();
|
||||
};
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the object store and extract the keys using the id
|
||||
*
|
||||
* @private
|
||||
* @param {String} uuid
|
||||
* @returns the result from the object store or false
|
||||
*/
|
||||
async _get_keys(uuid) {
|
||||
const self = this;
|
||||
return new Promise((resolve, reject) => {
|
||||
self._get_object_store().then((store) => {
|
||||
const request = store.get(uuid);
|
||||
request.onerror = function (event) {
|
||||
reject(`error opening database ${event.target.errorCode}`);
|
||||
};
|
||||
request.onsuccess = function () {
|
||||
resolve(request.result);
|
||||
};
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the keys exist in the database
|
||||
*
|
||||
* @returns the uuid of the currently active keys or false
|
||||
*/
|
||||
async _check_database() {
|
||||
const params = await this.rpc("/vault/keys/get");
|
||||
if (Object.keys(params).length && params.uuid) return params.uuid;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the keys exist in the store
|
||||
*
|
||||
* @private
|
||||
* @param {String} uuid
|
||||
* @returns if the keys are in the object store
|
||||
*/
|
||||
async _check_store(uuid) {
|
||||
if (!uuid) return false;
|
||||
|
||||
const result = await this._get_keys(uuid);
|
||||
return Boolean(result && result.keys);
|
||||
}
|
||||
|
||||
/**
|
||||
* Import the keys from the indexed DB
|
||||
*
|
||||
* @private
|
||||
* @returns if the import from the object store succeeded
|
||||
*/
|
||||
async _import_from_store() {
|
||||
const data = await this._get_keys(this.uuid);
|
||||
if (data) {
|
||||
this.keys = data.keys;
|
||||
this.time = data.time;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Export the current keys to the indexed DB
|
||||
*
|
||||
* @private
|
||||
* @returns true
|
||||
*/
|
||||
async _export_to_store() {
|
||||
const keys = {id: this.uuid, keys: this.keys, time: this.time};
|
||||
const store = await this._get_object_store();
|
||||
store.put(keys);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Export the key pairs to the backends
|
||||
*
|
||||
* @private
|
||||
* @param {String} password
|
||||
* @returns if the export to the database succeeded
|
||||
*/
|
||||
async _export_to_database(password = null) {
|
||||
// Generate salt for the user key
|
||||
this.salt = utils.generate_bytes(utils.SaltLength).buffer;
|
||||
this.iterations = utils.Derive.iterations;
|
||||
this.version = 1;
|
||||
|
||||
// Wrap the private key with the master key of the user
|
||||
this.iv = utils.generate_bytes(utils.IVLength);
|
||||
|
||||
// Request the password from the user and derive the user key
|
||||
const pass = await utils.derive_key(
|
||||
password || (await askpassword(true)),
|
||||
this.salt,
|
||||
this.iterations
|
||||
);
|
||||
|
||||
// Export the private key wrapped with the master key
|
||||
const private_key = await utils.export_private_key(
|
||||
await this.get_private_key(),
|
||||
pass,
|
||||
this.iv
|
||||
);
|
||||
|
||||
// Export the public key
|
||||
const public_key = await utils.export_public_key(await this.get_public_key());
|
||||
|
||||
const params = {
|
||||
public: public_key,
|
||||
private: private_key,
|
||||
iv: utils.toBase64(this.iv),
|
||||
iterations: this.iterations,
|
||||
salt: utils.toBase64(this.salt),
|
||||
version: this.version,
|
||||
};
|
||||
|
||||
// Export to the server
|
||||
const response = await this.rpc("/vault/keys/store", params);
|
||||
if (response) {
|
||||
this.uuid = response;
|
||||
return true;
|
||||
}
|
||||
|
||||
console.error("Failed to export keys to database");
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Import the keys from the backend and decrypt the private key
|
||||
*
|
||||
* @private
|
||||
* @returns if the import succeeded
|
||||
*/
|
||||
async _import_from_database() {
|
||||
const params = await this.rpc("/vault/keys/get");
|
||||
if (Object.keys(params).length) {
|
||||
this.salt = utils.fromBase64(params.salt);
|
||||
this.iterations = params.iterations;
|
||||
this.version = params.version || 0;
|
||||
|
||||
// Request the password from the user and derive the user key
|
||||
const raw_password = await askpassword(false);
|
||||
let password = raw_password;
|
||||
|
||||
// Compatibility
|
||||
if (!this.version) password = session.username + "|" + password;
|
||||
|
||||
const pass = await utils.derive_key(password, this.salt, this.iterations);
|
||||
|
||||
this.keys = {
|
||||
publicKey: await utils.load_public_key(params.public),
|
||||
privateKey: await utils.load_private_key(
|
||||
params.private,
|
||||
pass,
|
||||
params.iv
|
||||
),
|
||||
};
|
||||
|
||||
this.time = new Date();
|
||||
this.uuid = params.uuid;
|
||||
|
||||
this._check_key_migration(raw_password);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap the master key with the own public key
|
||||
*
|
||||
* @param {CryptoKey} master_key
|
||||
* @returns wrapped master key
|
||||
*/
|
||||
async wrap(master_key) {
|
||||
return await utils.wrap(master_key, await this.get_public_key());
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap the master key with a public key given as string
|
||||
*
|
||||
* @param {CryptoKey} master_key
|
||||
* @param {String} public_key
|
||||
* @returns wrapped master key
|
||||
*/
|
||||
async wrap_with(master_key, public_key) {
|
||||
const pub_key = await utils.load_public_key(public_key);
|
||||
return await utils.wrap(master_key, pub_key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unwrap the master key with the own private key
|
||||
*
|
||||
* @param {CryptoKey} master_key
|
||||
* @returns unwrapped master key
|
||||
*/
|
||||
async unwrap(master_key) {
|
||||
return await utils.unwrap(master_key, await this.get_private_key());
|
||||
}
|
||||
|
||||
/**
|
||||
* Share a wrapped master key by unwrapping with own private key and wrapping with
|
||||
* another key
|
||||
*
|
||||
* @param {String} master_key
|
||||
* @param {String} public_key
|
||||
* @returns wrapped master key
|
||||
*/
|
||||
async share(master_key, public_key) {
|
||||
const key = await this.unwrap(master_key);
|
||||
return await this.wrap_with(key, public_key);
|
||||
}
|
||||
}
|
||||
|
||||
export default new Vault();
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
.o_vault_inbox {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.o_field_cell .o_vault .o_vault_buttons {
|
||||
float: right;
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.o_vault .o_input {
|
||||
width: auto;
|
||||
}
|
||||
|
|
@ -0,0 +1,576 @@
|
|||
/** @odoo-module alias=vault.utils **/
|
||||
// © 2021-2024 Florian Kantelberg - initOS GmbH
|
||||
// License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
||||
|
||||
import {_t, qweb} from "web.core";
|
||||
import Dialog from "web.Dialog";
|
||||
|
||||
const CryptoAPI = window.crypto.subtle;
|
||||
|
||||
// Some basic constants used for the entire vaults
|
||||
const Hash = "SHA-512";
|
||||
|
||||
const HashLength = 10;
|
||||
const IVLength = 12;
|
||||
const SaltLength = 32;
|
||||
|
||||
const Asymmetric = {
|
||||
name: "RSA-OAEP",
|
||||
modulusLength: 4096,
|
||||
publicExponent: new Uint8Array([1, 0, 1]),
|
||||
hash: Hash,
|
||||
};
|
||||
const Derive = {
|
||||
name: "PBKDF2",
|
||||
iterations: 600001,
|
||||
};
|
||||
const Symmetric = {
|
||||
name: "AES-GCM",
|
||||
length: 256,
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks if the CryptoAPI is available and the vault feature supported
|
||||
*
|
||||
* @returns if vault is supported
|
||||
*/
|
||||
function supported() {
|
||||
return Boolean(CryptoAPI);
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts an ArrayBuffer to an ASCII string
|
||||
*
|
||||
* @param {ArrayBuffer} buffer
|
||||
* @returns the data converted to a string
|
||||
*/
|
||||
function toBinary(buffer) {
|
||||
if (!buffer) return "";
|
||||
|
||||
const chars = Array.from(new Uint8Array(buffer)).map(function (b) {
|
||||
return String.fromCharCode(b);
|
||||
});
|
||||
return chars.join("");
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts an ASCII string to an ArrayBuffer
|
||||
*
|
||||
* @param {String} binary
|
||||
* @returns the data converted to an ArrayBuffer
|
||||
*/
|
||||
function fromBinary(binary) {
|
||||
const len = binary.length;
|
||||
const bytes = new Uint8Array(len);
|
||||
for (let i = 0; i < len; i++) bytes[i] = binary.charCodeAt(i);
|
||||
return bytes.buffer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts an ArrayBuffer to a Base64 encoded string
|
||||
*
|
||||
* @param {ArrayBuffer} buffer
|
||||
* @returns Base64 string
|
||||
*/
|
||||
function toBase64(buffer) {
|
||||
if (!buffer) return "";
|
||||
|
||||
const chars = Array.from(new Uint8Array(buffer)).map(function (b) {
|
||||
return String.fromCharCode(b);
|
||||
});
|
||||
return btoa(chars.join(""));
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts an Base64 encoded string to an ArrayBuffer
|
||||
*
|
||||
* @param {String} base64
|
||||
* @returns the data converted to an ArrayBuffer
|
||||
*/
|
||||
function fromBase64(base64) {
|
||||
if (!base64) {
|
||||
const bytes = new Uint8Array(0);
|
||||
return bytes.buffer;
|
||||
}
|
||||
|
||||
const binary_string = atob(base64);
|
||||
const len = binary_string.length;
|
||||
const bytes = new Uint8Array(len);
|
||||
for (let i = 0; i < len; i++) bytes[i] = binary_string.charCodeAt(i);
|
||||
return bytes.buffer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate random bytes used for salts or IVs
|
||||
*
|
||||
* @param {int} length
|
||||
* @returns an array with length random bytes
|
||||
*/
|
||||
function generate_bytes(length) {
|
||||
const buf = new Uint8Array(length);
|
||||
window.crypto.getRandomValues(buf);
|
||||
return buf;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate random bytes used for salts or IVs encoded as base64
|
||||
*
|
||||
* @returns base64 string
|
||||
*/
|
||||
function generate_iv_base64() {
|
||||
return toBase64(generate_bytes(IVLength));
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a random secret with specific characters
|
||||
*
|
||||
* @param {int} length
|
||||
* @param {String} characters
|
||||
* @returns base64 string
|
||||
*/
|
||||
function generate_secret(length, characters) {
|
||||
let result = "";
|
||||
const len = characters.length;
|
||||
for (const k of generate_bytes(length))
|
||||
result += characters[Math.floor((len * k) / 256)];
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a symmetric key for encrypting and decrypting
|
||||
*
|
||||
* @returns symmetric key
|
||||
*/
|
||||
async function generate_key() {
|
||||
return await CryptoAPI.generateKey(Symmetric, true, ["encrypt", "decrypt"]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate an asymmetric key pair for encrypting, decrypting, wrapping and unwrapping
|
||||
*
|
||||
* @returns asymmetric key
|
||||
*/
|
||||
async function generate_key_pair() {
|
||||
return await CryptoAPI.generateKey(Asymmetric, true, [
|
||||
"wrapKey",
|
||||
"unwrapKey",
|
||||
"decrypt",
|
||||
"encrypt",
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a hash of the given data
|
||||
*
|
||||
* @param {String} data
|
||||
* @returns base64 encoded hash of the data
|
||||
*/
|
||||
async function digest(data) {
|
||||
const encoder = new TextEncoder();
|
||||
return toBase64(await CryptoAPI.digest(Hash, encoder.encode(data)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask the user to enter a password using a dialog
|
||||
*
|
||||
* @param {String} title of the dialog
|
||||
* @param {Object} options
|
||||
* @returns promise
|
||||
*/
|
||||
function askpass(title, options = {}) {
|
||||
var self = this;
|
||||
|
||||
if (options.password === undefined) options.password = true;
|
||||
if (options.keyfile === undefined) options.keyfile = true;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
var dialog = new Dialog(self, {
|
||||
title: title,
|
||||
$content: $(qweb.render("vault.askpass", options)),
|
||||
buttons: [
|
||||
{
|
||||
text: _t("Enter"),
|
||||
classes: "btn-primary",
|
||||
click: async function (ev) {
|
||||
ev.stopPropagation();
|
||||
const password = this.$("#password").val();
|
||||
const keyfile = this.$("#keyfile")[0].files[0];
|
||||
|
||||
if (!password && !keyfile) {
|
||||
Dialog.alert(this, _t("Missing password"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (options.confirm) {
|
||||
const confirm = this.$("#confirm").val();
|
||||
|
||||
if (confirm !== password) {
|
||||
Dialog.alert(this, _t("The passwords aren't matching"));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
dialog.close();
|
||||
|
||||
let keyfile_content = null;
|
||||
if (keyfile) keyfile_content = fromBinary(await keyfile.text());
|
||||
|
||||
resolve({
|
||||
password: password,
|
||||
keyfile: keyfile_content,
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
text: _t("Cancel"),
|
||||
click: function (ev) {
|
||||
ev.stopPropagation();
|
||||
dialog.close();
|
||||
reject(_t("Cancelled"));
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
dialog.open();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask the user to enter a password using a dialog
|
||||
*
|
||||
* @param {String} title of the dialog
|
||||
* @param {Object} options
|
||||
* @returns promise
|
||||
*/
|
||||
function generate_pass(title, options = {}) {
|
||||
var self = this;
|
||||
|
||||
const $content = $(qweb.render("vault.generate_pass", options));
|
||||
const $password = $content.find("#password")[0];
|
||||
const $length = $content.find("#length")[0];
|
||||
const $big = $content.find("#big_letter")[0];
|
||||
const $small = $content.find("#small_letter")[0];
|
||||
const $digits = $content.find("#digits")[0];
|
||||
const $special = $content.find("#special")[0];
|
||||
var password = null;
|
||||
|
||||
function gen_pass() {
|
||||
let characters = "";
|
||||
if ($big.checked) characters += "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
|
||||
if ($small.checked) characters += "abcdefghijklmnopqrstuvwxyz";
|
||||
if ($digits.checked) characters += "0123456789";
|
||||
if ($special.checked) characters += "!?$%&/()[]{}|<>,;.:-_#+*\\";
|
||||
|
||||
if (characters)
|
||||
$password.innerHTML = password = generate_secret($length.value, characters);
|
||||
}
|
||||
|
||||
$length.onchange =
|
||||
$big.onchange =
|
||||
$small.onchange =
|
||||
$digits.onchange =
|
||||
$special.onchange =
|
||||
gen_pass;
|
||||
|
||||
gen_pass();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
var dialog = new Dialog(self, {
|
||||
title: title,
|
||||
$content: $content,
|
||||
buttons: [
|
||||
{
|
||||
text: _t("Enter"),
|
||||
classes: "btn-primary",
|
||||
click: async function (ev) {
|
||||
ev.stopPropagation();
|
||||
if (!password) throw new Error(_t("Missing password"));
|
||||
|
||||
dialog.close();
|
||||
resolve(password);
|
||||
},
|
||||
},
|
||||
{
|
||||
text: _t("Cancel"),
|
||||
click: function (ev) {
|
||||
ev.stopPropagation();
|
||||
dialog.close();
|
||||
reject(_t("Cancelled"));
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
dialog.open();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive a key using the given data, salt and iterations using PBKDF2
|
||||
*
|
||||
* @param {String} data
|
||||
* @param {String} salt
|
||||
* @param {int} iterations
|
||||
* @returns the derived key
|
||||
*/
|
||||
async function derive_key(data, salt, iterations) {
|
||||
const enc = new TextEncoder();
|
||||
const material = await CryptoAPI.importKey(
|
||||
"raw",
|
||||
enc.encode(data),
|
||||
Derive.name,
|
||||
false,
|
||||
["deriveBits", "deriveKey"]
|
||||
);
|
||||
|
||||
return await CryptoAPI.deriveKey(
|
||||
{
|
||||
name: Derive.name,
|
||||
salt: salt,
|
||||
iterations: iterations,
|
||||
hash: Hash,
|
||||
},
|
||||
material,
|
||||
Symmetric,
|
||||
false,
|
||||
["wrapKey", "unwrapKey", "encrypt", "decrypt"]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt the data using a public key
|
||||
*
|
||||
* @param {CryptoKey} public_key
|
||||
* @param {String} data
|
||||
* @returns the encrypted data
|
||||
*/
|
||||
async function asym_encrypt(public_key, data) {
|
||||
if (!data) return data;
|
||||
|
||||
const enc = new TextEncoder();
|
||||
return toBase64(
|
||||
await CryptoAPI.encrypt({name: Asymmetric.name}, public_key, enc.encode(data))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt the data using the own private key
|
||||
*
|
||||
* @param {CryptoKey} private_key
|
||||
* @param {String} crypted
|
||||
* @returns the decrypted data
|
||||
*/
|
||||
async function asym_decrypt(private_key, crypted) {
|
||||
if (!crypted) return crypted;
|
||||
|
||||
const dec = new TextDecoder();
|
||||
return dec.decode(
|
||||
await CryptoAPI.decrypt(
|
||||
{name: Asymmetric.name},
|
||||
private_key,
|
||||
fromBase64(crypted)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Symmetrically encrypt the data using a master key
|
||||
*
|
||||
* @param {CryptoKey} key
|
||||
* @param {String} data
|
||||
* @param {String} iv
|
||||
* @returns the encrypted data
|
||||
*/
|
||||
async function sym_encrypt(key, data, iv) {
|
||||
if (!data) return data;
|
||||
|
||||
const hash = await digest(data);
|
||||
const enc = new TextEncoder();
|
||||
return toBase64(
|
||||
await CryptoAPI.encrypt(
|
||||
{name: Symmetric.name, iv: fromBase64(iv), tagLength: 128},
|
||||
key,
|
||||
enc.encode(hash.slice(0, HashLength) + data)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Symmetrically decrypt the data using a master key
|
||||
*
|
||||
* @param {CryptoKey} key
|
||||
* @param {String} crypted
|
||||
* @param {String} iv
|
||||
* @returns the decrypted data
|
||||
*/
|
||||
async function sym_decrypt(key, crypted, iv) {
|
||||
if (!crypted) return crypted;
|
||||
|
||||
try {
|
||||
const dec = new TextDecoder();
|
||||
const message = dec.decode(
|
||||
await CryptoAPI.decrypt(
|
||||
{name: Symmetric.name, iv: fromBase64(iv), tagLength: 128},
|
||||
key,
|
||||
fromBase64(crypted)
|
||||
)
|
||||
);
|
||||
|
||||
const data = message.slice(HashLength);
|
||||
const hash = await digest(data);
|
||||
// Compare the hash and return if integer
|
||||
if (hash.slice(0, HashLength) === message.slice(0, HashLength)) return data;
|
||||
|
||||
console.error("Invalid data hash");
|
||||
// Wrong hash
|
||||
return null;
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a public key
|
||||
*
|
||||
* @param {String} public_key
|
||||
* @returns the public key as CryptoKey
|
||||
*/
|
||||
async function load_public_key(public_key) {
|
||||
return await CryptoAPI.importKey("spki", fromBase64(public_key), Asymmetric, true, [
|
||||
"wrapKey",
|
||||
"encrypt",
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a private key
|
||||
*
|
||||
* @param {String} private_key
|
||||
* @param {CryptoKey} key
|
||||
* @param {String} iv
|
||||
* @returns the private key as CryptoKey
|
||||
*/
|
||||
async function load_private_key(private_key, key, iv) {
|
||||
return await CryptoAPI.unwrapKey(
|
||||
"pkcs8",
|
||||
fromBase64(private_key),
|
||||
key,
|
||||
{name: Symmetric.name, iv: fromBase64(iv), tagLength: 128},
|
||||
Asymmetric,
|
||||
true,
|
||||
["unwrapKey", "decrypt"]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Export a public key in spki format
|
||||
*
|
||||
* @param {CryptoKey} public_key
|
||||
* @returns the public key as string
|
||||
*/
|
||||
async function export_public_key(public_key) {
|
||||
return toBase64(await CryptoAPI.exportKey("spki", public_key));
|
||||
}
|
||||
|
||||
/**
|
||||
* Export a private key in pkcs8 format
|
||||
*
|
||||
* @param {String} private_key
|
||||
* @param {CryptoKey} key
|
||||
* @param {String} iv
|
||||
* @returns the public key as CryptoKey
|
||||
*/
|
||||
async function export_private_key(private_key, key, iv) {
|
||||
return toBase64(
|
||||
await CryptoAPI.wrapKey("pkcs8", private_key, key, {
|
||||
name: Symmetric.name,
|
||||
iv: iv,
|
||||
tagLength: 128,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap the master key with the own public key
|
||||
*
|
||||
* @param {CryptoKey} key
|
||||
* @param {CryptoKey} public_key
|
||||
* @returns wrapped master key
|
||||
*/
|
||||
async function wrap(key, public_key) {
|
||||
return toBase64(await CryptoAPI.wrapKey("raw", key, public_key, Asymmetric));
|
||||
}
|
||||
|
||||
/**
|
||||
* Unwrap the master key with the own private key
|
||||
*
|
||||
* @param {CryptoKey} key
|
||||
* @param {CryptoKey} private_key
|
||||
* @returns unwrapped master key
|
||||
*/
|
||||
async function unwrap(key, private_key) {
|
||||
return await CryptoAPI.unwrapKey(
|
||||
"raw",
|
||||
fromBase64(key),
|
||||
private_key,
|
||||
Asymmetric,
|
||||
Symmetric,
|
||||
true,
|
||||
["encrypt", "decrypt"]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Capitalize each word of the string
|
||||
*
|
||||
* @param {String} s
|
||||
* @returns capitalized string
|
||||
*/
|
||||
function capitalize(s) {
|
||||
return s.toLowerCase().replace(/\b\w/g, function (c) {
|
||||
return c.toUpperCase();
|
||||
});
|
||||
}
|
||||
|
||||
export default {
|
||||
// Constants
|
||||
Asymmetric: Asymmetric,
|
||||
Derive: Derive,
|
||||
Hash: Hash,
|
||||
HashLength: HashLength,
|
||||
IVLength: IVLength,
|
||||
SaltLength: SaltLength,
|
||||
Symmetric: Symmetric,
|
||||
|
||||
// Crypto utility functions
|
||||
askpass: askpass,
|
||||
asym_decrypt: asym_decrypt,
|
||||
asym_encrypt: asym_encrypt,
|
||||
derive_key: derive_key,
|
||||
digest: digest,
|
||||
export_private_key: export_private_key,
|
||||
export_public_key: export_public_key,
|
||||
generate_bytes: generate_bytes,
|
||||
generate_iv_base64: generate_iv_base64,
|
||||
generate_key: generate_key,
|
||||
generate_key_pair: generate_key_pair,
|
||||
generate_pass: generate_pass,
|
||||
generate_secret: generate_secret,
|
||||
load_private_key: load_private_key,
|
||||
load_public_key: load_public_key,
|
||||
sym_decrypt: sym_decrypt,
|
||||
sym_encrypt: sym_encrypt,
|
||||
unwrap: unwrap,
|
||||
wrap: wrap,
|
||||
|
||||
// Utility functions
|
||||
capitalize: capitalize,
|
||||
fromBase64: fromBase64,
|
||||
fromBinary: fromBinary,
|
||||
toBase64: toBase64,
|
||||
toBinary: toBinary,
|
||||
|
||||
supported: supported,
|
||||
};
|
||||
|
|
@ -0,0 +1,96 @@
|
|||
/** @odoo-module alias=vault.inbox **/
|
||||
// © 2021-2024 Florian Kantelberg - initOS GmbH
|
||||
// License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
||||
|
||||
import utils from "vault.utils";
|
||||
|
||||
const data = {};
|
||||
let key = false;
|
||||
let iv = false;
|
||||
|
||||
const fields = [
|
||||
"key",
|
||||
"iv",
|
||||
"public",
|
||||
"encrypted",
|
||||
"secret",
|
||||
"encrypted_file",
|
||||
"filename",
|
||||
"secret_file",
|
||||
"submit",
|
||||
];
|
||||
|
||||
function toggle_required(element, value) {
|
||||
if (value) element.setAttribute("required", "required");
|
||||
else element.removeAttribute("required");
|
||||
}
|
||||
|
||||
// Encrypt the value and store it in the right input field
|
||||
async function encrypt_and_store(value, target) {
|
||||
if (!utils.supported()) return false;
|
||||
|
||||
// Find all the possible elements which are needed
|
||||
for (const id of fields) if (!data[id]) data[id] = document.getElementById(id);
|
||||
|
||||
// We expect a public key here otherwise we can't procceed
|
||||
if (!data.public.value) return;
|
||||
|
||||
const public_key = await utils.load_public_key(data.public.value);
|
||||
|
||||
// Create a new key if not already present
|
||||
if (!key) {
|
||||
key = await utils.generate_key();
|
||||
data.key.value = await utils.wrap(key, public_key);
|
||||
}
|
||||
|
||||
// Create a new IV if not already present
|
||||
if (!iv) {
|
||||
iv = utils.generate_iv_base64();
|
||||
data.iv.value = iv;
|
||||
}
|
||||
|
||||
// Encrypt the value symmetrically and store it in the field
|
||||
const val = await utils.sym_encrypt(key, value, iv);
|
||||
data[target].value = val;
|
||||
return Boolean(val);
|
||||
}
|
||||
|
||||
document.getElementById("secret").onchange = async function () {
|
||||
if (!utils.supported()) return false;
|
||||
|
||||
if (!this.value) return;
|
||||
|
||||
const required = await encrypt_and_store(this.value, "encrypted");
|
||||
toggle_required(data.secret, required);
|
||||
toggle_required(data.secret_file, !required);
|
||||
data.submit.removeAttribute("disabled");
|
||||
};
|
||||
|
||||
document.getElementById("secret_file").onchange = async function () {
|
||||
if (!utils.supported()) return false;
|
||||
|
||||
if (!this.files.length) return;
|
||||
|
||||
const file = this.files[0];
|
||||
const reader = new FileReader();
|
||||
let content = null;
|
||||
|
||||
const promise = new Promise((resolve) => {
|
||||
reader.onload = () => {
|
||||
if (reader.result.indexOf(",") >= 0) content = reader.result.split(",")[1];
|
||||
resolve();
|
||||
};
|
||||
});
|
||||
|
||||
reader.readAsDataURL(file);
|
||||
|
||||
await promise;
|
||||
|
||||
if (!content) return;
|
||||
|
||||
const required = await encrypt_and_store(content, "encrypted_file");
|
||||
toggle_required(data.secret, !required);
|
||||
toggle_required(data.secret_file, required);
|
||||
data.filename.value = file.name;
|
||||
data.submit.removeAttribute("disabled");
|
||||
};
|
||||
|
|
@ -0,0 +1,209 @@
|
|||
// © 2021 Florian Kantelberg - initOS GmbH
|
||||
// License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
||||
|
||||
/* global QUnit */
|
||||
|
||||
odoo.define("vault.tests", function (require) {
|
||||
"use strict";
|
||||
|
||||
var utils = require("vault.utils");
|
||||
|
||||
QUnit.module(
|
||||
"vault",
|
||||
{
|
||||
before: function () {
|
||||
utils.askpass = async function () {
|
||||
return {
|
||||
password: "test",
|
||||
keyfile: "",
|
||||
};
|
||||
};
|
||||
},
|
||||
},
|
||||
function () {
|
||||
function is_keypair(keys, assert) {
|
||||
assert.equal(keys.publicKey instanceof CryptoKey, true);
|
||||
assert.equal(keys.publicKey.type, "public");
|
||||
assert.equal(keys.privateKey instanceof CryptoKey, true);
|
||||
assert.equal(keys.privateKey.type, "private");
|
||||
}
|
||||
|
||||
QUnit.test("vault: Test conversion utils", async function (assert) {
|
||||
assert.expect(7);
|
||||
|
||||
let text = "hello world";
|
||||
let buf = utils.fromBinary(text);
|
||||
assert.equal(true, buf instanceof ArrayBuffer);
|
||||
assert.equal(text, utils.toBinary(buf));
|
||||
assert.equal("", utils.toBinary(false));
|
||||
|
||||
text = "ImhlbGxvIHdvcmxkIg==";
|
||||
buf = utils.fromBase64(text);
|
||||
assert.equal(true, buf instanceof ArrayBuffer);
|
||||
assert.equal(text, utils.toBase64(buf));
|
||||
assert.equal("", utils.toBase64(false));
|
||||
|
||||
assert.equal("Hello World", utils.capitalize("hello world"));
|
||||
});
|
||||
|
||||
QUnit.test("vault: Test generation utils", async function (assert) {
|
||||
assert.expect(12);
|
||||
|
||||
let data = utils.generate_bytes(5);
|
||||
assert.equal(true, data instanceof Uint8Array);
|
||||
assert.equal(data.length, 5);
|
||||
data = utils.generate_bytes(10);
|
||||
assert.equal(data.length, 10);
|
||||
|
||||
data = utils.generate_iv_base64();
|
||||
assert.equal(typeof data, "string");
|
||||
assert.notEqual(data, utils.generate_iv_base64());
|
||||
|
||||
data = await utils.generate_key();
|
||||
assert.equal(true, data instanceof CryptoKey);
|
||||
|
||||
data = await utils.generate_key_pair();
|
||||
is_keypair(data, assert);
|
||||
|
||||
data = utils.generate_secret(10, "01");
|
||||
assert.equal(data.length, 10);
|
||||
let valid = true;
|
||||
for (const c of data) if ("01".indexOf(c) < 0) valid = false;
|
||||
assert.equal(valid, true);
|
||||
});
|
||||
|
||||
QUnit.test("vault: Test asymmetric encryption", async function (assert) {
|
||||
assert.expect(2);
|
||||
const text = "hello world";
|
||||
const key = await utils.generate_key_pair();
|
||||
|
||||
const crypted = await utils.asym_encrypt(key.publicKey, text);
|
||||
assert.equal("string", typeof crypted);
|
||||
assert.strictEqual(
|
||||
text,
|
||||
await utils.asym_decrypt(key.privateKey, crypted)
|
||||
);
|
||||
});
|
||||
|
||||
QUnit.test("vault: Test symmetric encryption", async function (assert) {
|
||||
assert.expect(2);
|
||||
const text = "hello world";
|
||||
const key = await utils.generate_key();
|
||||
const iv = utils.generate_iv_base64();
|
||||
|
||||
const crypted = await utils.sym_encrypt(key, text, iv);
|
||||
assert.equal("string", typeof crypted);
|
||||
assert.strictEqual(text, await utils.sym_decrypt(key, crypted, iv));
|
||||
});
|
||||
|
||||
QUnit.test("vault: Test import/export", async function (assert) {
|
||||
assert.expect(3);
|
||||
|
||||
const key = await utils.generate_key_pair();
|
||||
let exported = await utils.export_public_key(key.publicKey);
|
||||
let tmp = await utils.load_public_key(exported);
|
||||
assert.deepEqual(key.publicKey, tmp);
|
||||
|
||||
const iv = utils.generate_bytes(10);
|
||||
const salt = utils.generate_bytes(10);
|
||||
const wrapper = await utils.derive_key("test", salt, 4000);
|
||||
exported = await utils.export_private_key(key.privateKey, wrapper, iv);
|
||||
tmp = await utils.load_private_key(
|
||||
exported,
|
||||
wrapper,
|
||||
utils.toBase64(iv)
|
||||
);
|
||||
assert.deepEqual(key.privateKey, tmp);
|
||||
|
||||
const master_key = await utils.generate_key();
|
||||
exported = await utils.wrap(master_key, key.publicKey);
|
||||
tmp = await utils.unwrap(exported, key.privateKey);
|
||||
assert.deepEqual(master_key, tmp);
|
||||
});
|
||||
|
||||
QUnit.test("vault: Test vault class", async function (assert) {
|
||||
assert.expect(12);
|
||||
|
||||
var vault = require("vault");
|
||||
|
||||
await vault._initialize_keys();
|
||||
is_keypair(vault.keys, assert);
|
||||
|
||||
vault.keys = undefined;
|
||||
await vault._import_from_store();
|
||||
is_keypair(vault.keys, assert);
|
||||
|
||||
vault.keys = undefined;
|
||||
await vault._import_from_database();
|
||||
is_keypair(vault.keys, assert);
|
||||
});
|
||||
|
||||
QUnit.test("vault: Importer/exporter", async function (assert) {
|
||||
// The exporter won't skip empty keys
|
||||
const child = {
|
||||
uuid: "42a",
|
||||
note: "test note child",
|
||||
name: "test child",
|
||||
url: "child.example.org",
|
||||
fields: [],
|
||||
files: [],
|
||||
childs: [],
|
||||
};
|
||||
|
||||
const data = {
|
||||
type: "raw",
|
||||
data: [
|
||||
child,
|
||||
{
|
||||
uuid: "42",
|
||||
note: "test note",
|
||||
name: "test name",
|
||||
url: "test.example.org",
|
||||
fields: [
|
||||
{name: "a", value: "Hello World"},
|
||||
{name: "secret", value: "dlrow olleh"},
|
||||
],
|
||||
files: [],
|
||||
childs: [child, child],
|
||||
},
|
||||
child,
|
||||
],
|
||||
};
|
||||
|
||||
assert.expect(2);
|
||||
|
||||
var Exporter = require("vault.export");
|
||||
var Importer = require("vault.import");
|
||||
var vault = require("vault");
|
||||
await vault._initialize_keys();
|
||||
|
||||
const master_key = await utils.generate_key();
|
||||
const importer = new Importer();
|
||||
const imported = await importer.import(
|
||||
master_key,
|
||||
"test.json",
|
||||
JSON.stringify(data)
|
||||
);
|
||||
|
||||
const exporter = new Exporter();
|
||||
const exported = await exporter.export(
|
||||
master_key,
|
||||
"test.json",
|
||||
JSON.stringify(imported)
|
||||
);
|
||||
assert.equal(exported.type, "encrypted");
|
||||
|
||||
const pass = await utils.derive_key(
|
||||
"test",
|
||||
utils.fromBase64(exported.salt),
|
||||
exported.iterations
|
||||
);
|
||||
|
||||
const tmp = JSON.parse(
|
||||
await utils.sym_decrypt(pass, exported.data, exported.iv)
|
||||
);
|
||||
assert.deepEqual(tmp, data.data);
|
||||
});
|
||||
}
|
||||
);
|
||||
});
|
||||
11
odoo-bringout-oca-server-auth-vault/vault/tests/__init__.py
Normal file
11
odoo-bringout-oca-server-auth-vault/vault/tests/__init__.py
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
# © 2021 Florian Kantelberg - initOS GmbH
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
||||
|
||||
from . import (
|
||||
test_controller,
|
||||
test_log,
|
||||
test_rights,
|
||||
test_user,
|
||||
test_vault,
|
||||
test_widgets,
|
||||
)
|
||||
|
|
@ -0,0 +1,229 @@
|
|||
# © 2021 Florian Kantelberg - initOS GmbH
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
||||
|
||||
import json
|
||||
import logging
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from odoo.tests import TransactionCase
|
||||
from odoo.tools import mute_logger
|
||||
|
||||
from odoo.addons.website.tools import MockRequest
|
||||
|
||||
from ..controllers import main
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TestController(TransactionCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
|
||||
cls.controller = main.Controller()
|
||||
|
||||
cls.user = cls.env["res.users"].create(
|
||||
{"login": "test", "email": "test@test", "name": "test"}
|
||||
)
|
||||
cls.user.inbox_token = "42"
|
||||
cls.user.keys.current = False
|
||||
cls.key = cls.env["res.users.key"].create(
|
||||
{
|
||||
"user_id": cls.user.id,
|
||||
"public": "a public key",
|
||||
"salt": "42",
|
||||
"iv": "2424",
|
||||
"iterations": 4000,
|
||||
"private": "24",
|
||||
"current": True,
|
||||
}
|
||||
)
|
||||
cls.inbox = cls.env["vault.inbox"].create(
|
||||
{
|
||||
"user_id": cls.user.id,
|
||||
"name": "Inbox",
|
||||
"key": "4",
|
||||
"iv": "1",
|
||||
"secret": "old secret",
|
||||
"secret_file": "old file",
|
||||
"accesses": 100,
|
||||
}
|
||||
)
|
||||
|
||||
@mute_logger("odoo.sql_db")
|
||||
def test_vault_inbox(self):
|
||||
def return_context(template, context):
|
||||
self.assertEqual(template, "vault.inbox")
|
||||
return json.dumps(context)
|
||||
|
||||
def load(response):
|
||||
return json.loads(response.data)
|
||||
|
||||
with MockRequest(self.env) as request_mock:
|
||||
request_mock.render = return_context
|
||||
response = load(self.controller.vault_inbox(""))
|
||||
self.assertIn("error", response)
|
||||
|
||||
response = load(self.controller.vault_inbox(self.user.inbox_token))
|
||||
self.assertNotIn("error", response)
|
||||
self.assertEqual(response["public"], self.user.active_key.public)
|
||||
|
||||
# Try to eliminate each error step by step
|
||||
request_mock.httprequest.method = "POST"
|
||||
request_mock.params = {}
|
||||
response = load(self.controller.vault_inbox(self.user.inbox_token))
|
||||
self.assertIn("error", response)
|
||||
|
||||
request_mock.params["name"] = "test"
|
||||
response = load(self.controller.vault_inbox(self.user.inbox_token))
|
||||
self.assertIn("error", response)
|
||||
|
||||
request_mock.params.update(
|
||||
{"encrypted": "secret", "encrypted_file": "file"}
|
||||
)
|
||||
response = load(self.controller.vault_inbox(self.user.inbox_token))
|
||||
self.assertIn("error", response)
|
||||
|
||||
request_mock.params["filename"] = "filename"
|
||||
response = load(self.controller.vault_inbox(self.user.inbox_token))
|
||||
self.assertIn("error", response)
|
||||
|
||||
self.assertEqual(self.inbox.secret, "old secret")
|
||||
self.assertEqual(self.inbox.secret_file, b"old file")
|
||||
|
||||
# Store something successfully
|
||||
request_mock.params.update({"iv": "iv", "key": "key"})
|
||||
response = load(self.controller.vault_inbox(self.inbox.token))
|
||||
self.assertNotIn("error", response)
|
||||
self.assertEqual(self.inbox.secret, "secret")
|
||||
self.assertEqual(self.inbox.secret_file, b"file")
|
||||
|
||||
# Test a duplicate inbox
|
||||
self.inbox.copy().token = self.inbox.token
|
||||
response = load(self.controller.vault_inbox(self.inbox.token))
|
||||
self.assertIn("error", response)
|
||||
|
||||
def raise_error(*args, **kwargs):
|
||||
raise TypeError()
|
||||
|
||||
# Catch internal errors
|
||||
try:
|
||||
request_mock.httprequest.remote_addr = "127.0.0.1"
|
||||
self.env["vault.inbox"]._patch_method("store_in_inbox", raise_error)
|
||||
response = load(self.controller.vault_inbox(self.user.inbox_token))
|
||||
finally:
|
||||
self.env["vault.inbox"]._revert_method("store_in_inbox")
|
||||
|
||||
self.assertIn("error", response)
|
||||
|
||||
@mute_logger("odoo.sql_db")
|
||||
def test_vault_public(self):
|
||||
with MockRequest(self.env):
|
||||
no_key = self.env["res.users"].create(
|
||||
{"login": "keyless", "email": "test@test", "name": "test"}
|
||||
)
|
||||
|
||||
response = self.controller.vault_public(user_id=no_key.id)
|
||||
self.assertEqual(response, {})
|
||||
|
||||
response = self.controller.vault_public(user_id=self.user.id)
|
||||
self.assertEqual(response["public_key"], self.key.public)
|
||||
|
||||
@mute_logger("odoo.sql_db")
|
||||
def test_vault_replace(self):
|
||||
with MockRequest(self.env):
|
||||
vault = self.env["vault"].create({"name": "Vault"})
|
||||
right = vault.right_ids[:1]
|
||||
entry = self.env["vault.entry"].create(
|
||||
{"name": "Test Entry", "vault_id": vault.id}
|
||||
)
|
||||
field = self.env["vault.field"].create(
|
||||
{"entry_id": entry.id, "name": "Test", "value": "hello"}
|
||||
)
|
||||
file = self.env["vault.file"].create(
|
||||
{"entry_id": entry.id, "name": "Test", "value": b"hello"}
|
||||
)
|
||||
right.write({"key": "invalid"})
|
||||
|
||||
self.controller.vault_replace(None)
|
||||
self.assertEqual(field.value, "hello")
|
||||
self.assertEqual(file.value, b"hello")
|
||||
|
||||
vault.reencrypt_required = True
|
||||
self.controller.vault_replace(
|
||||
[
|
||||
{"model": field._name, "id": field.id, "value": "test"},
|
||||
{"model": file._name, "id": file.id, "value": "test"},
|
||||
{"model": right._name, "id": right.id, "key": "changed"},
|
||||
]
|
||||
)
|
||||
self.assertEqual(field.value, "test")
|
||||
self.assertEqual(file.value, b"test")
|
||||
self.assertEqual(right.key, "changed")
|
||||
self.assertFalse(vault.reencrypt_required)
|
||||
|
||||
@mute_logger("odoo.sql_db")
|
||||
def test_vault_store(
|
||||
self,
|
||||
):
|
||||
with MockRequest(self.env):
|
||||
mock = MagicMock()
|
||||
try:
|
||||
self.env["res.users.key"]._patch_method("store", mock)
|
||||
self.controller.vault_store_keys()
|
||||
mock.assert_called_once()
|
||||
finally:
|
||||
self.env["res.users.key"]._revert_method("store")
|
||||
|
||||
@mute_logger("odoo.sql_db")
|
||||
def test_vault_keys_get(self):
|
||||
with MockRequest(self.env):
|
||||
mock = MagicMock()
|
||||
try:
|
||||
self.env["res.users"]._patch_method("get_vault_keys", mock)
|
||||
self.controller.vault_get_keys()
|
||||
mock.assert_called_once()
|
||||
finally:
|
||||
self.env["res.users"]._revert_method("get_vault_keys")
|
||||
|
||||
@mute_logger("odoo.sql_db")
|
||||
def test_vault_right_keys(self):
|
||||
with MockRequest(self.env):
|
||||
self.assertFalse(self.controller.vault_get_right_keys())
|
||||
|
||||
# New vault with user as owner and only right
|
||||
vault = self.env["vault"].create({"name": "Vault"})
|
||||
|
||||
response = self.controller.vault_get_right_keys()
|
||||
self.assertEqual(response, {vault.uuid: vault.right_ids.key})
|
||||
|
||||
@mute_logger("odoo.sql_db")
|
||||
def test_vault_store_right_key(self):
|
||||
with MockRequest(self.env):
|
||||
vault = self.env["vault"].create({"name": "Vault"})
|
||||
|
||||
self.controller.vault_store_right_keys(None)
|
||||
|
||||
self.controller.vault_store_right_keys({vault.uuid: "new key"})
|
||||
self.assertEqual(vault.right_ids.key, "new key")
|
||||
|
||||
@mute_logger("odoo.sql_db")
|
||||
def test_vault_inbox_keys(self):
|
||||
with MockRequest(self.env):
|
||||
self.assertFalse(self.controller.vault_get_inbox())
|
||||
|
||||
inbox = self.inbox.copy({"user_id": self.env.uid})
|
||||
|
||||
response = self.controller.vault_get_inbox()
|
||||
self.assertEqual(response, {inbox.token: inbox.key})
|
||||
|
||||
@mute_logger("odoo.sql_db")
|
||||
def test_vault_store_inbox_key(self):
|
||||
with MockRequest(self.env):
|
||||
inbox = self.inbox.copy({"user_id": self.env.uid})
|
||||
inbox.user_id = self.env.user
|
||||
|
||||
self.controller.vault_store_inbox(None)
|
||||
|
||||
self.controller.vault_store_inbox({inbox.token: "new key"})
|
||||
self.assertEqual(inbox.key, "new key")
|
||||
115
odoo-bringout-oca-server-auth-vault/vault/tests/test_inbox.py
Normal file
115
odoo-bringout-oca-server-auth-vault/vault/tests/test_inbox.py
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
# © 2021 Florian Kantelberg - initOS GmbH
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from uuid import uuid4
|
||||
|
||||
from odoo.tests import TransactionCase
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TestShare(TransactionCase):
|
||||
def test_user_inbox(self):
|
||||
user = self.env["res.users"].create(
|
||||
{"login": "test", "email": "test@test", "name": "test"}
|
||||
)
|
||||
|
||||
user.action_new_inbox_token()
|
||||
|
||||
model = self.env["res.users"]
|
||||
token = user.inbox_token
|
||||
|
||||
self.assertEqual(user, model.find_user_of_inbox(token))
|
||||
self.assertIn(token, user.inbox_link)
|
||||
|
||||
user.inbox_enabled = False
|
||||
self.assertEqual(model, model.find_user_of_inbox(token))
|
||||
|
||||
user.action_new_inbox_token()
|
||||
self.assertNotEqual(user.inbox_token, token)
|
||||
|
||||
def test_inbox(self):
|
||||
model = self.env["vault.inbox"]
|
||||
user = self.env.user
|
||||
vals = {
|
||||
"name": f"Inbox {user.name}",
|
||||
"secret": "secret",
|
||||
"iv": "iv",
|
||||
"user": user,
|
||||
"key": "key",
|
||||
"secret_file": "",
|
||||
"filename": "",
|
||||
}
|
||||
|
||||
# Should create a new inbox for the user
|
||||
inbox = model.store_in_inbox(**vals)
|
||||
self.assertEqual(inbox.secret, "secret")
|
||||
self.assertEqual(inbox.accesses, 0)
|
||||
self.assertIn(inbox.token, inbox.inbox_link)
|
||||
|
||||
# No change because of no accesses left
|
||||
vals["secret"] = "new secret"
|
||||
inbox.store_in_inbox(**vals)
|
||||
self.assertEqual(inbox.secret, "secret")
|
||||
self.assertEqual(inbox.accesses, 0)
|
||||
|
||||
# Change expected because 5 accesses left
|
||||
inbox.accesses = 5
|
||||
inbox.store_in_inbox(**vals)
|
||||
self.assertEqual(inbox.secret, "new secret")
|
||||
self.assertEqual(inbox.accesses, 4)
|
||||
|
||||
# No change because expired
|
||||
vals["secret"] = "newer secret"
|
||||
inbox.expiration = datetime(1970, 1, 1)
|
||||
inbox.store_in_inbox(**vals)
|
||||
self.assertEqual(inbox.secret, "new secret")
|
||||
self.assertEqual(inbox.accesses, 4)
|
||||
|
||||
# Search for shares
|
||||
self.assertEqual(inbox, model.find_inbox(inbox.token))
|
||||
self.assertEqual(model, model.find_inbox(uuid4()))
|
||||
|
||||
def test_send_wizard(self):
|
||||
user = self.env.user
|
||||
wiz = self.env["vault.send.wizard"].create(
|
||||
{
|
||||
"name": uuid4(),
|
||||
"iv": "iv",
|
||||
"key_user": "key",
|
||||
"key": "k",
|
||||
"secret": uuid4(),
|
||||
"user_id": user.id,
|
||||
}
|
||||
)
|
||||
|
||||
# Create a new inbox
|
||||
wiz.action_send()
|
||||
self.assertTrue(self.env["vault.inbox"].search([("name", "=", wiz.name)]))
|
||||
|
||||
def test_store_wizard(self):
|
||||
vault = self.env["vault"].create({"name": "Vault"})
|
||||
|
||||
entry = self.env["vault.entry"].create({"vault_id": vault.id, "name": "Entry"})
|
||||
|
||||
wiz = self.env["vault.store.wizard"].create(
|
||||
{
|
||||
"vault_id": vault.id,
|
||||
"entry_id": entry.id,
|
||||
"name": uuid4(),
|
||||
"iv": "iv",
|
||||
"key": "k",
|
||||
"secret": uuid4(),
|
||||
"secret_temporary": "temp",
|
||||
"model": "vault.field",
|
||||
}
|
||||
)
|
||||
|
||||
vault.right_ids.write({"key": uuid4()})
|
||||
self.assertEqual(wiz.master_key, vault.right_ids.key)
|
||||
|
||||
wiz.action_store()
|
||||
|
||||
self.assertEqual(wiz.name, entry.field_ids.name)
|
||||
41
odoo-bringout-oca-server-auth-vault/vault/tests/test_log.py
Normal file
41
odoo-bringout-oca-server-auth-vault/vault/tests/test_log.py
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
# © 2021 Florian Kantelberg - initOS GmbH
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
||||
|
||||
import logging
|
||||
|
||||
from odoo.tests import TransactionCase
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TestLog(TransactionCase):
|
||||
def test_not_implemeneted(self):
|
||||
with self.assertRaises(NotImplementedError):
|
||||
self.env["vault.abstract"].log_entry("test")
|
||||
|
||||
with self.assertRaises(NotImplementedError):
|
||||
self.env["vault.abstract"].log_info("test")
|
||||
|
||||
with self.assertRaises(NotImplementedError):
|
||||
self.env["vault.abstract"].log_warn("test")
|
||||
|
||||
with self.assertRaises(NotImplementedError):
|
||||
self.env["vault.abstract"].log_error("test")
|
||||
|
||||
def test_log_created(self):
|
||||
vault = self.env["vault"].create({"name": "Vault"})
|
||||
entry = self.env["vault.entry"].create({"vault_id": vault.id, "name": "Entry"})
|
||||
|
||||
vault.log_ids.unlink()
|
||||
|
||||
vault.log_info("info")
|
||||
self.assertEqual(vault.log_ids.mapped("state"), ["info"])
|
||||
self.assertEqual(entry.log_ids.mapped("state"), [])
|
||||
|
||||
entry.log_warn("warn")
|
||||
self.assertEqual(vault.log_ids.mapped("state"), ["info", "warn"])
|
||||
self.assertEqual(entry.log_ids.mapped("state"), ["warn"])
|
||||
|
||||
entry.log_error("error")
|
||||
self.assertEqual(vault.log_ids.mapped("state"), ["info", "warn", "error"])
|
||||
self.assertEqual(entry.log_ids.mapped("state"), ["warn", "error"])
|
||||
155
odoo-bringout-oca-server-auth-vault/vault/tests/test_rights.py
Normal file
155
odoo-bringout-oca-server-auth-vault/vault/tests/test_rights.py
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
# © 2021 Florian Kantelberg - initOS GmbH
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
||||
|
||||
import logging
|
||||
|
||||
from odoo.exceptions import AccessError
|
||||
from odoo.tests import TransactionCase
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TestAccessRights(TransactionCase):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.user = self.env["res.users"].create(
|
||||
{"login": "user", "name": "tester", "email": "user@localhost"}
|
||||
)
|
||||
self.vault = self.env["vault"].create({"name": "Vault"})
|
||||
self.entry = self.env["vault.entry"].create(
|
||||
{"vault_id": self.vault.id, "name": "Entry"}
|
||||
)
|
||||
self.field = self.env["vault.field"].create(
|
||||
{"entry_id": self.entry.id, "name": "Field", "value": "Value"}
|
||||
)
|
||||
self.vault.right_ids.write({"key": "Owner"})
|
||||
|
||||
def test_vault_reencrypt(self):
|
||||
right = self.env["vault.right"].create(
|
||||
{
|
||||
"vault_id": self.vault.id,
|
||||
"user_id": self.user.id,
|
||||
"perm_create": False,
|
||||
}
|
||||
)
|
||||
|
||||
assert not self.vault.reencrypt_required
|
||||
right.unlink()
|
||||
assert self.vault.reencrypt_required
|
||||
|
||||
def test_public_key(self):
|
||||
key = self.env["res.users.key"].create(
|
||||
{
|
||||
"user_id": self.vault.user_id.id,
|
||||
"public": "a public key",
|
||||
"salt": "42",
|
||||
"iv": "2424",
|
||||
"iterations": 4000,
|
||||
"private": "24",
|
||||
}
|
||||
)
|
||||
self.assertTrue(self.vault.right_ids.public_key)
|
||||
self.assertEqual(key.public, self.vault.right_ids.public_key)
|
||||
|
||||
def test_owner_access(self):
|
||||
# The owner can always access despite the permissions
|
||||
for obj in [self.field, self.entry, self.vault]:
|
||||
obj.name = "Owned"
|
||||
|
||||
right = self.vault.right_ids
|
||||
right.perm_write = False
|
||||
obj.name = "Owned"
|
||||
|
||||
right.perm_delete = False
|
||||
obj.unlink()
|
||||
|
||||
def test_no_create(self):
|
||||
self.env["vault.right"].create(
|
||||
{
|
||||
"vault_id": self.vault.id,
|
||||
"user_id": self.user.id,
|
||||
"perm_create": False,
|
||||
}
|
||||
)
|
||||
|
||||
for obj in [self.field, self.entry, self.vault]:
|
||||
with self.assertRaises(AccessError):
|
||||
obj.with_user(self.user).check_access_rule("create")
|
||||
|
||||
def test_no_right(self):
|
||||
# No right defined for test user means access denied
|
||||
for obj in [self.field, self.entry, self.vault]:
|
||||
with self.assertRaises(AccessError):
|
||||
self.assertTrue(obj.with_user(self.user).read())
|
||||
|
||||
with self.assertRaises(AccessError):
|
||||
obj.with_user(self.user).name = "Owned"
|
||||
|
||||
with self.assertRaises(AccessError):
|
||||
obj.with_user(self.user).unlink()
|
||||
|
||||
def test_no_permission(self):
|
||||
# Defined right but no write permission means access denied
|
||||
self.env["vault.right"].create(
|
||||
{
|
||||
"vault_id": self.vault.id,
|
||||
"user_id": self.user.id,
|
||||
"perm_create": False,
|
||||
"perm_write": False,
|
||||
"perm_delete": False,
|
||||
}
|
||||
)
|
||||
for obj in [self.field, self.entry, self.vault]:
|
||||
self.assertTrue(obj.with_user(self.user).read())
|
||||
|
||||
with self.assertRaises(AccessError):
|
||||
obj.with_user(self.user).name = "Owned"
|
||||
|
||||
with self.assertRaises(AccessError):
|
||||
obj.with_user(self.user).unlink()
|
||||
|
||||
def test_granted(self):
|
||||
# Granted write permission allows writing
|
||||
self.env["vault.right"].create(
|
||||
{
|
||||
"vault_id": self.vault.id,
|
||||
"user_id": self.user.id,
|
||||
"perm_write": True,
|
||||
"perm_delete": True,
|
||||
}
|
||||
)
|
||||
for obj in [self.field, self.entry, self.vault]:
|
||||
self.assertTrue(obj.with_user(self.user).read())
|
||||
|
||||
obj.with_user(self.user).name = "Owned"
|
||||
obj.with_user(self.user).unlink()
|
||||
|
||||
def test_owner_share(self):
|
||||
self.env["vault.right"].create(
|
||||
{"vault_id": self.vault.id, "user_id": self.user.id}
|
||||
)
|
||||
|
||||
def test_user_share_no_right(self):
|
||||
# No right defined means AccessError
|
||||
with self.assertRaises(AccessError):
|
||||
self.env["vault.right"].with_user(self.user).create(
|
||||
{"vault_id": self.vault.id, "user_id": 2}
|
||||
)
|
||||
|
||||
def test_user_share_no_permission(self):
|
||||
# Created right but no permission to share
|
||||
right = self.env["vault.right"].create(
|
||||
{"vault_id": self.vault.id, "user_id": self.user.id, "perm_share": False}
|
||||
)
|
||||
|
||||
with self.assertRaises(AccessError):
|
||||
right.with_user(self.user).create({"vault_id": self.vault.id, "user_id": 2})
|
||||
|
||||
def test_user_share_granted(self):
|
||||
# Granted permission to share
|
||||
right = self.env["vault.right"].create(
|
||||
{"vault_id": self.vault.id, "user_id": self.user.id, "perm_share": True}
|
||||
)
|
||||
right.with_user(self.user).create({"vault_id": self.vault.id, "user_id": 2})
|
||||
|
||||
right.unlink()
|
||||
60
odoo-bringout-oca-server-auth-vault/vault/tests/test_user.py
Normal file
60
odoo-bringout-oca-server-auth-vault/vault/tests/test_user.py
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
# © 2021 Florian Kantelberg - initOS GmbH
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
||||
|
||||
import logging
|
||||
|
||||
from odoo.tests import TransactionCase
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TestShare(TransactionCase):
|
||||
def test_user_inbox(self):
|
||||
user = self.env["res.users"].create(
|
||||
{"login": "test", "email": "test@test", "name": "test"}
|
||||
)
|
||||
|
||||
user.action_new_inbox_token()
|
||||
|
||||
model = self.env["res.users"]
|
||||
token = user.inbox_token
|
||||
|
||||
self.assertEqual(user, model.find_user_of_inbox(token))
|
||||
self.assertIn(token, user.inbox_link)
|
||||
|
||||
user.inbox_enabled = False
|
||||
self.assertEqual(model, model.find_user_of_inbox(token))
|
||||
|
||||
user.action_new_inbox_token()
|
||||
self.assertNotEqual(user.inbox_token, token)
|
||||
|
||||
def test_user_key_management(self):
|
||||
action = self.env.ref("vault.action_res_users_keys")
|
||||
|
||||
self.assertEqual(action.id, self.env["res.users"].action_get_vault()["id"])
|
||||
|
||||
def test_invalidation(self):
|
||||
self.env["res.users.key"].store(
|
||||
40000, "invalid", "invalid", "invalid", "invalid", 42
|
||||
)
|
||||
self.assertTrue(self.env.user.keys.filtered("current"))
|
||||
|
||||
vault = self.env["vault"].create({"name": "Test"})
|
||||
self.assertTrue(vault.right_ids)
|
||||
|
||||
inbox = self.env["vault.inbox"].create(
|
||||
{
|
||||
"name": "Inbox Test",
|
||||
"secret": "secret",
|
||||
"iv": "iv",
|
||||
"user_id": self.env.uid,
|
||||
"key": "key",
|
||||
"secret_file": "",
|
||||
"filename": "",
|
||||
}
|
||||
)
|
||||
|
||||
self.env.user.action_invalidate_key()
|
||||
self.assertFalse(self.env.user.keys.filtered("current"))
|
||||
self.assertFalse(inbox.exists())
|
||||
self.assertFalse(vault.right_ids.exists())
|
||||
166
odoo-bringout-oca-server-auth-vault/vault/tests/test_vault.py
Normal file
166
odoo-bringout-oca-server-auth-vault/vault/tests/test_vault.py
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
# © 2021 Florian Kantelberg - initOS GmbH
|
||||
# Copyright 2022 Tecnativa - Víctor Martínez
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
from odoo.exceptions import ValidationError
|
||||
from odoo.tests import TransactionCase
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TestVault(TransactionCase):
|
||||
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"}
|
||||
)
|
||||
self.child = self.env["vault.entry"].create(
|
||||
{"vault_id": self.vault.id, "name": "Child", "parent_id": self.entry.id}
|
||||
)
|
||||
|
||||
def test_entry_path(self):
|
||||
self.assertEqual(self.entry.complete_name, "Entry")
|
||||
self.assertEqual(self.child.complete_name, "Entry / Child")
|
||||
|
||||
def test_wizard_actions(self):
|
||||
values = self.child.action_open_import_wizard()
|
||||
self.assertEqual(values["context"]["default_parent_id"], self.child.id)
|
||||
|
||||
values = self.vault.action_open_import_wizard()
|
||||
self.assertNotIn("default_parent_id", values["context"])
|
||||
|
||||
values = self.child.action_open_export_wizard()
|
||||
self.assertEqual(values["context"]["default_entry_id"], self.child.id)
|
||||
|
||||
values = self.vault.action_open_export_wizard()
|
||||
self.assertNotIn("default_entry_id", values["context"])
|
||||
|
||||
def test_master_key(self):
|
||||
right = self.vault.right_ids
|
||||
self.assertEqual(self.vault.master_key, right.master_key)
|
||||
|
||||
self.vault.master_key = "test"
|
||||
self.assertEqual(right.key, "test")
|
||||
|
||||
def test_share_public_key(self):
|
||||
key = self.env["res.users.key"].create(
|
||||
{
|
||||
"user_id": self.vault.user_id.id,
|
||||
"public": "a public key",
|
||||
"salt": "42",
|
||||
"iv": "2424",
|
||||
"iterations": 4000,
|
||||
"private": "24",
|
||||
}
|
||||
)
|
||||
|
||||
expected = {"user": 1, "public": key.public}
|
||||
self.assertIn(expected, self.vault.share_public_keys())
|
||||
|
||||
def test_keys(self):
|
||||
key = self.env["res.users.key"].create(
|
||||
{
|
||||
"user_id": self.vault.user_id.id,
|
||||
"public": "a public key",
|
||||
"salt": "42",
|
||||
"iv": "2424",
|
||||
"iterations": 4000,
|
||||
"private": "24",
|
||||
}
|
||||
)
|
||||
|
||||
self.assertEqual(set("0123456789abcdef:"), set(key.fingerprint))
|
||||
|
||||
key.public = ""
|
||||
self.assertEqual(key.fingerprint, False)
|
||||
|
||||
def test_store_keys(self):
|
||||
model = self.env["res.users.key"]
|
||||
|
||||
# Raise some errors because of wrong parameters
|
||||
with self.assertRaises(ValidationError):
|
||||
model.store(1, "iv", "private", "public", 42, 42)
|
||||
|
||||
with self.assertRaises(ValidationError):
|
||||
model.store(3000, "iv", "private", "public", "salt", 42)
|
||||
|
||||
with self.assertRaises(ValidationError):
|
||||
model.store(4000, "iv", "private", "public", "salt", "abc")
|
||||
|
||||
# Actually store a new key
|
||||
uuid = model.store(4000, "iv", "private", "public", "salt", 42)
|
||||
rec = model.search([("uuid", "=", uuid)])
|
||||
self.assertEqual(rec.private, "private")
|
||||
self.assertTrue(rec.current)
|
||||
|
||||
# Don't store the same again
|
||||
uuid = model.store(4000, "iv", "private", "public", "salt", 42)
|
||||
self.assertFalse(uuid)
|
||||
|
||||
# Store a new one and disable the old one
|
||||
uuid = model.store(4000, "iv", "more private", "public", "salt", 42)
|
||||
self.assertFalse(rec.current)
|
||||
|
||||
rec = model.search([("uuid", "=", uuid)])
|
||||
self.assertEqual(rec.private, "more private")
|
||||
self.assertTrue(rec.current)
|
||||
|
||||
# Try to extract the public key again
|
||||
user_id = self.env["res.users"].search([], limit=1, order="id DESC").id
|
||||
public = model.extract_public_key(user_id + 1)
|
||||
self.assertFalse(public)
|
||||
public = model.extract_public_key(self.env.uid)
|
||||
self.assertEqual(public, "public")
|
||||
|
||||
def test_vault_keys(self):
|
||||
keys = self.env.user.get_vault_keys()
|
||||
self.assertEqual(keys, {})
|
||||
|
||||
data = {
|
||||
"user_id": self.env.user.id,
|
||||
"public": "a public key",
|
||||
"salt": "42",
|
||||
"iv": "2424",
|
||||
"iterations": 4000,
|
||||
"private": "24",
|
||||
}
|
||||
self.env["res.users.key"].create(data)
|
||||
|
||||
keys = self.env.user.get_vault_keys()
|
||||
for key in ["private", "public", "iv", "salt", "iterations"]:
|
||||
self.assertEqual(keys[key], data[key])
|
||||
|
||||
def test_vault_entry_recursion(self):
|
||||
child = self.env["vault.entry"].create(
|
||||
{"vault_id": self.vault.id, "name": "Entry", "parent_id": self.entry.id}
|
||||
)
|
||||
|
||||
with self.assertRaises(ValidationError):
|
||||
self.entry.parent_id = child.id
|
||||
|
||||
def test_search_expired(self):
|
||||
entry = self.env["vault.entry"]
|
||||
self.assertEqual(entry._search_expired("in", []), [])
|
||||
|
||||
domain = entry._search_expired("=", True)
|
||||
self.assertEqual(domain[0][:2], ("expire_date", "<"))
|
||||
self.assertIsInstance(domain[0][2], datetime)
|
||||
|
||||
domain = entry._search_expired("!=", False)
|
||||
self.assertEqual(domain[0][:2], ("expire_date", "<"))
|
||||
self.assertIsInstance(domain[0][2], datetime)
|
||||
|
||||
domain = entry._search_expired("=", False)
|
||||
self.assertTrue(domain[0] == "|")
|
||||
self.assertIn(("expire_date", "=", False), domain)
|
||||
self.assertTrue(any(("expire_date", ">=") == d[:2] for d in domain))
|
||||
|
||||
def test_vault_entry_search_panel_limit(self):
|
||||
res = self.entry.search_panel_select_range("parent_id")
|
||||
total_items = self.env["vault.entry"].search_count([("child_ids", "!=", False)])
|
||||
self.assertEqual(len(res["values"]), total_items)
|
||||
156
odoo-bringout-oca-server-auth-vault/vault/tests/test_widgets.py
Normal file
156
odoo-bringout-oca-server-auth-vault/vault/tests/test_widgets.py
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
# © 2021 Florian Kantelberg - initOS GmbH
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
||||
|
||||
import json
|
||||
import logging
|
||||
from uuid import uuid4
|
||||
|
||||
from odoo.exceptions import UserError
|
||||
from odoo.tests import TransactionCase
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
TestChild = {
|
||||
"uuid": "42a",
|
||||
"note": "test note child",
|
||||
"name": "test child",
|
||||
"url": "child.example.org",
|
||||
"fields": [],
|
||||
"files": [],
|
||||
"childs": [],
|
||||
}
|
||||
|
||||
TestData = [
|
||||
{
|
||||
"uuid": "42b",
|
||||
"note": "test note child",
|
||||
"name": "don't import",
|
||||
"url": "child.example.org",
|
||||
"fields": [],
|
||||
"files": [],
|
||||
"childs": [],
|
||||
},
|
||||
TestChild,
|
||||
{
|
||||
"uuid": "42",
|
||||
"note": "test note",
|
||||
"name": "test name",
|
||||
"url": "test.example.org",
|
||||
"fields": [
|
||||
{"name": "a", "value": "Hello World", "iv": "abcd"},
|
||||
{"name": "secret", "value": "dlrow olleh", "iv": "abcd"},
|
||||
{"name": "secret", "value": "dlrow olle", "iv": "abcd"},
|
||||
],
|
||||
"files": [
|
||||
{"name": "a", "value": "Hello World", "iv": "abcd"},
|
||||
{"name": "secret", "value": "dlrow olleh", "iv": "abcd"},
|
||||
{},
|
||||
],
|
||||
"childs": [
|
||||
{
|
||||
"uuid": "42aa",
|
||||
"note": "test note subchild",
|
||||
"name": "test subchild",
|
||||
"url": "subchild.example.org",
|
||||
"fields": [],
|
||||
"files": [],
|
||||
"childs": [],
|
||||
},
|
||||
TestChild,
|
||||
],
|
||||
},
|
||||
TestChild,
|
||||
]
|
||||
|
||||
|
||||
class TestWidgets(TransactionCase):
|
||||
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"}
|
||||
)
|
||||
|
||||
def test_path_generation(self):
|
||||
wiz = self.env["vault.import.wizard"].create(
|
||||
{"vault_id": self.vault.id, "crypted_content": json.dumps(TestData)}
|
||||
)
|
||||
wiz._onchange_content()
|
||||
|
||||
paths = wiz.path.search([("uuid", "=", wiz.uuid)]).mapped("name")
|
||||
|
||||
self.assertEqual(len(paths), 6)
|
||||
self.assertIn("test name / test child", paths)
|
||||
self.assertIn("test child", paths)
|
||||
self.assertIn("test name", paths)
|
||||
|
||||
def test_import(self):
|
||||
uuid = uuid4()
|
||||
path = self.env["vault.import.wizard.path"].create(
|
||||
{"name": "test", "uuid": uuid}
|
||||
)
|
||||
|
||||
wiz = self.env["vault.import.wizard"].create(
|
||||
{
|
||||
"vault_id": self.vault.id,
|
||||
"crypted_content": json.dumps(TestData),
|
||||
"path": path.id,
|
||||
"uuid": uuid,
|
||||
}
|
||||
)
|
||||
|
||||
wiz.action_import()
|
||||
|
||||
# We have duplicates
|
||||
uuids = {"42", "42a", "42aa", self.entry.uuid}
|
||||
self.assertSetEqual(set(self.vault.entry_ids.mapped("uuid")), uuids)
|
||||
|
||||
# Creation is depth-first which will cause the 42a to move up
|
||||
self.assertEqual(self.vault.entry_ids.mapped("child_ids.uuid"), ["42aa"])
|
||||
|
||||
# This will cause an overwrite of the field
|
||||
domain = [("entry_id.uuid", "=", "42"), ("name", "=", "secret")]
|
||||
rec = self.env["vault.field"].search(domain)
|
||||
self.assertEqual(rec.mapped("value"), ["dlrow olle"])
|
||||
|
||||
# Field with missing keys should fail
|
||||
with self.assertRaises(UserError):
|
||||
TestChild["fields"].append({"name": "12", "value": "eulav"})
|
||||
wiz.crypted_content = json.dumps([TestChild])
|
||||
wiz.action_import()
|
||||
|
||||
def test_export(self):
|
||||
child = self.env["vault.entry"].create(
|
||||
{"vault_id": self.vault.id, "name": "Child", "parent_id": self.entry.id}
|
||||
)
|
||||
|
||||
second = self.env["vault.entry"].create(
|
||||
{"vault_id": self.vault.id, "name": "second"}
|
||||
)
|
||||
|
||||
wiz = self.env["vault.export.wizard"].create({"vault_id": self.vault.id})
|
||||
|
||||
# Export without entry should export entire vault
|
||||
wiz._onchange_content()
|
||||
entries = json.loads(wiz.content)
|
||||
self.assertEqual({e["uuid"] for e in entries}, {second.uuid, self.entry.uuid})
|
||||
self.assertEqual(len(entries), 2)
|
||||
|
||||
wiz.entry_id = self.entry
|
||||
|
||||
# Export the entire tree
|
||||
wiz._onchange_content()
|
||||
entries = json.loads(wiz.content)
|
||||
self.assertEqual(len(entries), 1)
|
||||
self.assertEqual(entries[0]["uuid"], self.entry.uuid)
|
||||
self.assertEqual(entries[0]["childs"][0]["uuid"], child.uuid)
|
||||
|
||||
# Skip exporting childs
|
||||
wiz.include_childs = False
|
||||
wiz._onchange_content()
|
||||
entries = json.loads(wiz.content)
|
||||
self.assertEqual(len(entries), 1)
|
||||
self.assertEqual(entries[0]["uuid"], self.entry.uuid)
|
||||
self.assertEqual(len(entries[0]["childs"]), 0)
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<odoo>
|
||||
<record id="action_vault" model="ir.actions.act_window">
|
||||
<field name="name">Vault</field>
|
||||
<field name="res_model">vault</field>
|
||||
<field name="view_mode">tree,form</field>
|
||||
</record>
|
||||
|
||||
<record id="action_vault_entry" model="ir.actions.act_window">
|
||||
<field name="name">All Entries</field>
|
||||
<field name="res_model">vault.entry</field>
|
||||
<field name="view_mode">tree,form</field>
|
||||
<field name="context">{'search_default_vault': 1}</field>
|
||||
<field
|
||||
name="view_ids"
|
||||
eval="[(5,0,0),
|
||||
(0,0,{'view_mode':'tree', 'view_id': ref('view_vault_entry_full_tree')}),
|
||||
(0,0,{'view_mode':'form', 'view_id': ref('view_vault_entry_overview_form')})]"
|
||||
/>
|
||||
</record>
|
||||
|
||||
<record id="action_vault_inbox" model="ir.actions.act_window">
|
||||
<field name="name">Inbox</field>
|
||||
<field name="res_model">vault.inbox</field>
|
||||
<field name="view_mode">tree,form</field>
|
||||
</record>
|
||||
|
||||
<record id="action_vault_right" model="ir.actions.act_window">
|
||||
<field name="name">Rights</field>
|
||||
<field name="res_model">vault.right</field>
|
||||
<field name="view_mode">tree</field>
|
||||
<field name="view_id" ref="view_vault_right_overview_tree" />
|
||||
<field name="search_view_id" ref="view_vault_right_overview_search" />
|
||||
</record>
|
||||
|
||||
<menuitem
|
||||
id="menu_vault"
|
||||
groups="base.group_user"
|
||||
action="action_vault"
|
||||
web_icon="vault,static/description/icon.png"
|
||||
/>
|
||||
<menuitem
|
||||
id="menu_vault_entry"
|
||||
groups="base.group_user"
|
||||
parent="menu_vault"
|
||||
action="action_vault_entry"
|
||||
sequence="30"
|
||||
/>
|
||||
<menuitem
|
||||
id="menu_vault_inbox"
|
||||
groups="base.group_user"
|
||||
parent="vault.menu_vault"
|
||||
action="action_vault_inbox"
|
||||
sequence="40"
|
||||
/>
|
||||
<menuitem
|
||||
id="menu_vault_right"
|
||||
groups="base.group_user"
|
||||
parent="vault.menu_vault"
|
||||
action="action_vault_right"
|
||||
sequence="50"
|
||||
/>
|
||||
</odoo>
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<odoo>
|
||||
<record id="res_config_settings_view_form" model="ir.ui.view">
|
||||
<field name="name">res.config.settings.view.form</field>
|
||||
<field name="model">res.config.settings</field>
|
||||
<field name="inherit_id" ref="base_setup.res_config_settings_view_form" />
|
||||
<field name="arch" type="xml">
|
||||
<div id="user_default_rights" position="after">
|
||||
<h2>Vault</h2>
|
||||
<div class="row mt16 o_settings_container" id="vault">
|
||||
<div class="col-xs-12 col-md-6 o_setting_box" id="vault_share">
|
||||
<div class="o_setting_left_pane">
|
||||
<field name="module_vault_share" />
|
||||
</div>
|
||||
<div class="o_setting_right_pane">
|
||||
<label for="module_vault_share" string="Vault Share" />
|
||||
<div class="text-muted">
|
||||
Allow the usage to share secrets with external users
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-xs-12 col-md-6 o_setting_box" id="vault_share">
|
||||
<div class="o_setting_left_pane">
|
||||
<field name="group_vault_import" />
|
||||
</div>
|
||||
<div class="o_setting_right_pane">
|
||||
<label for="group_vault_import" />
|
||||
<div class="text-muted">
|
||||
Allow all users to import vaults accessible to them
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-xs-12 col-md-6 o_setting_box" id="vault_share">
|
||||
<div class="o_setting_left_pane">
|
||||
<field name="group_vault_export" />
|
||||
</div>
|
||||
<div class="o_setting_right_pane">
|
||||
<label for="group_vault_export" />
|
||||
<div class="text-muted">
|
||||
Allow all users to export vaults accessible to them
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
|
|
@ -0,0 +1,78 @@
|
|||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<odoo>
|
||||
<record id="view_res_users_key_tree" model="ir.ui.view">
|
||||
<field name="model">res.users.key</field>
|
||||
<field name="arch" type="xml">
|
||||
<tree editable="bottom">
|
||||
<field name="current" readonly="1" />
|
||||
<field name="fingerprint" readonly="1" />
|
||||
</tree>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_res_users_key_form" model="ir.ui.view">
|
||||
<field name="model">res.users.key</field>
|
||||
<field name="arch" type="xml">
|
||||
<form>
|
||||
<sheet>
|
||||
<group>
|
||||
<field name="fingerprint" readonly="1" />
|
||||
</group>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_users_form_keys_modif" model="ir.ui.view">
|
||||
<field name="model">res.users</field>
|
||||
<field name="arch" type="xml">
|
||||
<form>
|
||||
<sheet>
|
||||
<group>
|
||||
<field name="inbox_link" widget="url" />
|
||||
<field name="inbox_enabled" />
|
||||
</group>
|
||||
|
||||
<field name="keys" />
|
||||
</sheet>
|
||||
<footer>
|
||||
<button
|
||||
name="vault_generate_key"
|
||||
string="New private key"
|
||||
class="btn-primary"
|
||||
/>
|
||||
<button
|
||||
name="action_new_inbox_token"
|
||||
type="object"
|
||||
string="New inbox link"
|
||||
class="btn-primary"
|
||||
/>
|
||||
<button
|
||||
name="action_invalidate_key"
|
||||
type="object"
|
||||
string="Invalidate private key"
|
||||
class="btn-secondary"
|
||||
confirm="You will loose access to all vaults and your inbox. Do you want to continue?"
|
||||
/>
|
||||
<button special="cancel" string="Cancel" class="btn-secondary" />
|
||||
</footer>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
|
||||
<record id="action_res_users_keys" model="ir.actions.act_window">
|
||||
<field name="name">Manage my keys</field>
|
||||
<field name="type">ir.actions.act_window</field>
|
||||
<field name="res_model">res.users</field>
|
||||
<field name="target">new</field>
|
||||
<field name="view_mode">form</field>
|
||||
</record>
|
||||
|
||||
<record id="action_res_users_keys_view" model="ir.actions.act_window.view">
|
||||
<field eval="10" name="sequence" />
|
||||
<field name="view_mode">form</field>
|
||||
<field name="view_id" ref="view_users_form_keys_modif" />
|
||||
<field name="act_window_id" ref="action_res_users_keys" />
|
||||
</record>
|
||||
</odoo>
|
||||
|
|
@ -0,0 +1,93 @@
|
|||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<odoo>
|
||||
<template id="inbox">
|
||||
<t t-call="web.login_layout">
|
||||
<t t-call-assets="vault.assets_frontend" t-css="false" defer_load="True" />
|
||||
|
||||
<form
|
||||
class="oe_login_form"
|
||||
role="form"
|
||||
t-attf-action="/vault/inbox/{{ token }}"
|
||||
method="post"
|
||||
onsubmit="this.action = this.action + location.hash"
|
||||
>
|
||||
<input
|
||||
type="hidden"
|
||||
name="csrf_token"
|
||||
t-att-value="request.csrf_token()"
|
||||
/>
|
||||
<input type="hidden" id="token" name="token" t-att-value="token" />
|
||||
<input type="hidden" id="public" name="public" t-att-value="public" />
|
||||
<input type="hidden" id="encrypted" name="encrypted" />
|
||||
<input type="hidden" id="encrypted_file" name="encrypted_file" />
|
||||
<input type="hidden" id="filename" name="filename" />
|
||||
<input type="hidden" id="iv" name="iv" />
|
||||
<input type="hidden" id="key" name="key" />
|
||||
|
||||
<div class="form-group">
|
||||
<label for="name">Name of your secret:</label>
|
||||
<input
|
||||
type="text"
|
||||
t-if="name"
|
||||
name="name"
|
||||
readonly="readonly"
|
||||
class="form-control"
|
||||
t-att-value="name"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
t-else=""
|
||||
name="name"
|
||||
required="required"
|
||||
autofocus="autofocus"
|
||||
class="form-control"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="secret">Secret to share:</label>
|
||||
<input
|
||||
placeholder="Secret"
|
||||
type="text"
|
||||
id="secret"
|
||||
name="secret"
|
||||
required="required"
|
||||
autofocus="autofocus"
|
||||
class="form-control"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="secret">File to share:</label>
|
||||
<input
|
||||
type="file"
|
||||
placeholder="Secret"
|
||||
id="secret_file"
|
||||
name="secret_file"
|
||||
required="required"
|
||||
class="form-control"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p class="alert alert-danger" t-if="error" role="alert" t-esc="error" />
|
||||
<p
|
||||
class="alert alert-success"
|
||||
t-if="message"
|
||||
role="status"
|
||||
t-esc="message"
|
||||
/>
|
||||
|
||||
<div
|
||||
t-attf-class="clearfix text-center mb-1 {{'pt-2' if form_small else 'pt-3'}}"
|
||||
>
|
||||
<button
|
||||
id="submit"
|
||||
type="submit"
|
||||
class="btn btn-primary btn-block"
|
||||
disabled="disabled"
|
||||
>Submit secret</button>
|
||||
</div>
|
||||
</form>
|
||||
</t>
|
||||
</template>
|
||||
</odoo>
|
||||
|
|
@ -0,0 +1,191 @@
|
|||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<odoo>
|
||||
<record id="view_vault_entry_tree" model="ir.ui.view">
|
||||
<field name="model">vault.entry</field>
|
||||
<field name="arch" type="xml">
|
||||
<tree decoration-muted="expired">
|
||||
<field name="expired" invisible="1" />
|
||||
<field name="complete_name" />
|
||||
<field name="tags" widget="many2many_tags" />
|
||||
</tree>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_vault_entry_full_tree" model="ir.ui.view">
|
||||
<field name="model">vault.entry</field>
|
||||
<field name="arch" type="xml">
|
||||
<tree decoration-muted="expired">
|
||||
<field name="expired" invisible="1" />
|
||||
<field name="vault_id" />
|
||||
<field name="complete_name" />
|
||||
<field name="tags" widget="many2many_tags" />
|
||||
</tree>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_vault_entry_form" model="ir.ui.view">
|
||||
<field name="model">vault.entry</field>
|
||||
<field name="arch" type="xml">
|
||||
<form>
|
||||
<header>
|
||||
<button
|
||||
type="object"
|
||||
name="action_open_import_wizard"
|
||||
string="Import from file"
|
||||
groups="vault.group_vault_import"
|
||||
/>
|
||||
<button
|
||||
type="object"
|
||||
name="action_open_export_wizard"
|
||||
string="Export to file"
|
||||
groups="vault.group_vault_export"
|
||||
/>
|
||||
</header>
|
||||
<sheet>
|
||||
<field name="perm_user" invisible="1" />
|
||||
<field name="allowed_create" invisible="1" />
|
||||
<field name="allowed_share" invisible="1" />
|
||||
<field name="allowed_write" invisible="1" />
|
||||
<field name="allowed_delete" invisible="1" />
|
||||
|
||||
<group>
|
||||
<group>
|
||||
<field
|
||||
name="vault_id"
|
||||
invisible="1"
|
||||
force_save="1"
|
||||
attrs="{'readonly': [('vault_id', '!=', False)]}"
|
||||
/>
|
||||
<field
|
||||
name="parent_id"
|
||||
options="{'no_open': true, 'no_create_edit': true, 'no_quick_create': true}"
|
||||
/>
|
||||
<field name="complete_name" invisible="1" />
|
||||
<field name="name" />
|
||||
<field name="url" widget="url" />
|
||||
<field name="tags" widget="many2many_tags" />
|
||||
</group>
|
||||
<group>
|
||||
<field name="create_date" />
|
||||
<field name="write_date" />
|
||||
<field name="expire_date" />
|
||||
</group>
|
||||
</group>
|
||||
|
||||
<notebook>
|
||||
<page string="Content">
|
||||
<label for="field_ids" />
|
||||
<field
|
||||
name="field_ids"
|
||||
context="{'default_entry_id': active_id}"
|
||||
options="{'create': [('allowed_create', '=', True)], 'delete': [('allowed_delete', '=', True)], 'no_open': true}"
|
||||
>
|
||||
<tree editable="bottom">
|
||||
<field name="vault_id" invisible="1" />
|
||||
<field name="entry_id" invisible="1" />
|
||||
<field name="iv" invisible="1" />
|
||||
<field name="master_key" invisible="1" />
|
||||
<field name="name" />
|
||||
<field
|
||||
name="value"
|
||||
widget="vault_field"
|
||||
type="field_type"
|
||||
/>
|
||||
<field name="write_date" />
|
||||
</tree>
|
||||
</field>
|
||||
|
||||
<label for="file_ids" />
|
||||
<field
|
||||
name="file_ids"
|
||||
context="{'default_entry_id': active_id}"
|
||||
options="{'create': [('allowed_create', '=', True)], 'delete': [('allowed_delete', '=', True)], 'no_open': True}"
|
||||
>
|
||||
<tree editable="bottom">
|
||||
<field name="vault_id" invisible="1" />
|
||||
<field name="entry_id" invisible="1" />
|
||||
<field name="iv" invisible="1" />
|
||||
<field name="master_key" invisible="1" />
|
||||
<field name="name" invisible="1" />
|
||||
<field
|
||||
name="value"
|
||||
widget="vault_file"
|
||||
filename="name"
|
||||
/>
|
||||
<field name="write_date" />
|
||||
</tree>
|
||||
</field>
|
||||
</page>
|
||||
<page string="Note">
|
||||
<field name="note" />
|
||||
</page>
|
||||
<page string="Childs">
|
||||
<field
|
||||
name="child_ids"
|
||||
context="{'default_parent_id': active_id, 'default_vault_id': vault_id}"
|
||||
/>
|
||||
</page>
|
||||
<page string="Log">
|
||||
<field name="log_ids" options="{'no_open': True}" />
|
||||
</page>
|
||||
</notebook>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_vault_entry_overview_form" model="ir.ui.view">
|
||||
<field name="model">vault.entry</field>
|
||||
<field name="mode">primary</field>
|
||||
<field name="priority">100</field>
|
||||
<field name="inherit_id" ref="view_vault_entry_form" />
|
||||
<field name="arch" type="xml">
|
||||
<field name="vault_id" position="attributes">
|
||||
<attribute name="invisible">0</attribute>
|
||||
</field>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_vault_entry_search" model="ir.ui.view">
|
||||
<field name="name">vault.entry.search</field>
|
||||
<field name="model">vault.entry</field>
|
||||
<field name="arch" type="xml">
|
||||
<search>
|
||||
<field name="complete_name" operator="ilike" />
|
||||
<field name="name" operator="ilike" />
|
||||
<field name="tags" operator="ilike" />
|
||||
<field name="note" operator="ilike" />
|
||||
<filter
|
||||
string="Expired"
|
||||
name="expired"
|
||||
domain="[('expired', '=', True)]"
|
||||
/>
|
||||
<filter
|
||||
string="Not Expired"
|
||||
name="not_expired"
|
||||
domain="[('expired', '!=', True)]"
|
||||
/>
|
||||
<filter
|
||||
string="Vault"
|
||||
name="vault"
|
||||
domain="[]"
|
||||
context="{'group_by': 'vault_id'}"
|
||||
/>
|
||||
<searchpanel>
|
||||
<field
|
||||
name="vault_id"
|
||||
string="Vaults"
|
||||
enable_counters="1"
|
||||
limit="0"
|
||||
/>
|
||||
<field
|
||||
name="parent_id"
|
||||
string="Entries"
|
||||
enable_counters="1"
|
||||
limit="0"
|
||||
/>
|
||||
</searchpanel>
|
||||
</search>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<odoo>
|
||||
<record id="view_vault_field_form" model="ir.ui.view">
|
||||
<field name="model">vault.field</field>
|
||||
<field name="arch" type="xml">
|
||||
<form>
|
||||
<sheet>
|
||||
<group>
|
||||
<field name="vault_id" invisible="1" />
|
||||
<field name="entry_id" invisible="1" />
|
||||
<field name="iv" invisible="1" />
|
||||
<field name="master_key" invisible="1" />
|
||||
<field name="name" />
|
||||
<field name="value" widget="vault" type="field_type" />
|
||||
<field name="write_date" />
|
||||
</group>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_vault_field_tree" model="ir.ui.view">
|
||||
<field name="model">vault.field</field>
|
||||
<field name="arch" type="xml">
|
||||
<tree create="false" delete="false">
|
||||
<field name="vault_id" invisible="1" />
|
||||
<field name="entry_id" invisible="1" />
|
||||
<field name="iv" invisible="1" />
|
||||
<field name="master_key" invisible="1" />
|
||||
<field name="name" />
|
||||
<field name="value" widget="vault" type="field_type" />
|
||||
<field name="write_date" />
|
||||
</tree>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<odoo>
|
||||
<record id="view_vault_file_form" model="ir.ui.view">
|
||||
<field name="model">vault.file</field>
|
||||
<field name="arch" type="xml">
|
||||
<form>
|
||||
<sheet>
|
||||
<group>
|
||||
<field name="vault_id" invisible="1" />
|
||||
<field name="entry_id" invisible="1" />
|
||||
<field name="iv" invisible="1" />
|
||||
<field name="master_key" invisible="1" />
|
||||
<field name="name" invisible="1" />
|
||||
<field name="value" widget="vault_file" filename="name" />
|
||||
<field name="write_date" />
|
||||
</group>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_vault_file_tree" model="ir.ui.view">
|
||||
<field name="model">vault.file</field>
|
||||
<field name="arch" type="xml">
|
||||
<tree create="false" delete="false">
|
||||
<field name="vault_id" invisible="1" />
|
||||
<field name="entry_id" invisible="1" />
|
||||
<field name="iv" invisible="1" />
|
||||
<field name="master_key" invisible="1" />
|
||||
<field name="name" invisible="1" />
|
||||
<field name="value" widget="vault_file" filename="name" />
|
||||
<field name="write_date" />
|
||||
</tree>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<odoo>
|
||||
<record id="view_vault_inbox_tree" model="ir.ui.view">
|
||||
<field name="model">vault.inbox</field>
|
||||
<field name="arch" type="xml">
|
||||
<tree create="false">
|
||||
<field name="name" />
|
||||
<field name="inbox_link" widget="url" />
|
||||
</tree>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_vault_inbox_form" model="ir.ui.view">
|
||||
<field name="model">vault.inbox</field>
|
||||
<field name="arch" type="xml">
|
||||
<form create="false">
|
||||
<sheet>
|
||||
<field name="user_id" invisible="1" />
|
||||
<field name="iv" invisible="1" />
|
||||
<field name="key" invisible="1" />
|
||||
<field name="filename" invisible="1" />
|
||||
<group>
|
||||
<field name="inbox_link" widget="url" />
|
||||
<field name="name" />
|
||||
<field name="accesses" />
|
||||
<field name="expiration" />
|
||||
<field
|
||||
name="secret"
|
||||
widget="vault_inbox_field"
|
||||
attrs="{'invisible': [('secret', '=', False)]}"
|
||||
/>
|
||||
<field
|
||||
name="secret_file"
|
||||
filename="filename"
|
||||
widget="vault_inbox_file"
|
||||
attrs="{'invisible': [('secret_file', '=', False)]}"
|
||||
/>
|
||||
</group>
|
||||
|
||||
<label for="log_ids" />
|
||||
<field name="log_ids" options="{'no_open': True}">
|
||||
<tree>
|
||||
<field name="name" />
|
||||
<field name="create_date" />
|
||||
</tree>
|
||||
</field>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<odoo>
|
||||
<record id="view_vault_log_tree" model="ir.ui.view">
|
||||
<field name="model">vault.log</field>
|
||||
<field name="arch" type="xml">
|
||||
<tree
|
||||
decoration-danger="state == 'error'"
|
||||
decoration-warning="state == 'warn'"
|
||||
decoration-info="state == 'info'"
|
||||
>
|
||||
<field name="user_id" />
|
||||
<field name="message" />
|
||||
<field name="create_date" />
|
||||
<field name="state" invisible="1" />
|
||||
</tree>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
|
|
@ -0,0 +1,73 @@
|
|||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<odoo>
|
||||
<record id="view_right_tree" model="ir.ui.view">
|
||||
<field name="model">vault.right</field>
|
||||
<field name="arch" type="xml">
|
||||
<tree editable="bottom" decoration-danger="not key">
|
||||
<field name="vault_id" invisible="1" />
|
||||
<field name="master_key" invisible="1" />
|
||||
<field name="key" invisible="1" />
|
||||
<field name="public_key" invisible="1" />
|
||||
<field name="user_id" />
|
||||
<field name="perm_create" />
|
||||
<field name="perm_write" />
|
||||
<field name="perm_share" />
|
||||
<field name="perm_delete" />
|
||||
</tree>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_right_form" model="ir.ui.view">
|
||||
<field name="model">vault.right</field>
|
||||
<field name="arch" type="xml">
|
||||
<form>
|
||||
<sheet>
|
||||
<group>
|
||||
<field name="user_id" />
|
||||
<field name="perm_create" />
|
||||
<field name="perm_write" />
|
||||
<field name="perm_share" />
|
||||
<field name="perm_delete" />
|
||||
</group>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_vault_right_overview_tree" model="ir.ui.view">
|
||||
<field name="model">vault.right</field>
|
||||
<field name="arch" type="xml">
|
||||
<tree create="false">
|
||||
<field name="vault_id" />
|
||||
<field name="user_id" />
|
||||
<field name="perm_create" />
|
||||
<field name="perm_write" />
|
||||
<field name="perm_share" />
|
||||
<field name="perm_delete" />
|
||||
</tree>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_vault_right_overview_search" model="ir.ui.view">
|
||||
<field name="name">vault.right.overview.search</field>
|
||||
<field name="model">vault.right</field>
|
||||
<field name="arch" type="xml">
|
||||
<search>
|
||||
<field name="user_id" operator="ilike" />
|
||||
<field name="vault_id" operator="ilike" />
|
||||
<group expand="0" string="Grouped">
|
||||
<filter
|
||||
name="user_id"
|
||||
string="By user"
|
||||
context="{'group_by': 'user_id'}"
|
||||
/>
|
||||
<filter
|
||||
name="vault_id"
|
||||
string="By vault"
|
||||
context="{'group_by': 'vault_id'}"
|
||||
/>
|
||||
</group>
|
||||
</search>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
109
odoo-bringout-oca-server-auth-vault/vault/views/vault_views.xml
Normal file
109
odoo-bringout-oca-server-auth-vault/vault/views/vault_views.xml
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<odoo>
|
||||
<record id="view_vault_search" model="ir.ui.view">
|
||||
<field name="name">vault.search</field>
|
||||
<field name="model">vault</field>
|
||||
<field name="arch" type="xml">
|
||||
<search>
|
||||
<field name="name" operator="ilike" />
|
||||
<field name="note" operator="ilike" />
|
||||
</search>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_open_entries" model="ir.actions.act_window">
|
||||
<field name="name">Entries</field>
|
||||
<field name="res_model">vault.entry</field>
|
||||
<field name="view_mode">tree,form</field>
|
||||
<field name="domain">[("vault_id", "=", active_id)]</field>
|
||||
<field name="context">{
|
||||
"default_vault_id": active_id,
|
||||
"searchpanel_default_vault_id": active_id}
|
||||
</field>
|
||||
<field name="search_view_id" ref="view_vault_entry_search" />
|
||||
</record>
|
||||
|
||||
<record id="view_vault_tree" model="ir.ui.view">
|
||||
<field name="model">vault</field>
|
||||
<field name="arch" type="xml">
|
||||
<tree>
|
||||
<field name="name" />
|
||||
<field name="user_id" />
|
||||
<field name="note" />
|
||||
</tree>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_vault_form" model="ir.ui.view">
|
||||
<field name="model">vault</field>
|
||||
<field name="arch" type="xml">
|
||||
<form>
|
||||
<header>
|
||||
<button
|
||||
type="object"
|
||||
name="action_open_import_wizard"
|
||||
string="Import from file"
|
||||
groups="vault.group_vault_import"
|
||||
/>
|
||||
<button
|
||||
type="object"
|
||||
name="action_open_export_wizard"
|
||||
string="Export to file"
|
||||
groups="vault.group_vault_export"
|
||||
/>
|
||||
<button name="vault_verify" string="Verify" />
|
||||
<button
|
||||
name="vault_reencrypt"
|
||||
string="Re-encrypt"
|
||||
attrs="{'invisible': [('reencrypt_required', '=', True)]}"
|
||||
/>
|
||||
<button
|
||||
name="vault_reencrypt"
|
||||
string="Re-encrypt"
|
||||
class="oe_highlight"
|
||||
attrs="{'invisible': [('reencrypt_required', '=', False)]}"
|
||||
/>
|
||||
</header>
|
||||
<sheet>
|
||||
<div class="oe_button_box" name="button_box">
|
||||
<button
|
||||
class="oe_stat_button"
|
||||
name="vault.action_open_entries"
|
||||
string="Entries"
|
||||
type="action"
|
||||
icon="fa-bars"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<group>
|
||||
<field name="reencrypt_required" invisible="1" />
|
||||
<field name="allowed_share" invisible="1" />
|
||||
<field name="allowed_write" invisible="1" />
|
||||
<field name="allowed_create" invisible="1" />
|
||||
<field name="allowed_delete" invisible="1" />
|
||||
<field name="master_key" invisible="1" required="1" />
|
||||
<field name="name" />
|
||||
<field name="user_id" />
|
||||
<field name="note" />
|
||||
</group>
|
||||
|
||||
<notebook>
|
||||
<page
|
||||
string="Rights"
|
||||
attrs="{'invisible':[('allowed_share', '=', False)]}"
|
||||
>
|
||||
<field
|
||||
name="right_ids"
|
||||
context="{'default_vault_id': active_id}"
|
||||
options="{'create': [('allowed_share', '=', True)], 'delete': [('allowed_share', '=', True)]}"
|
||||
/>
|
||||
</page>
|
||||
<page string="Log">
|
||||
<field name="log_ids" options="{'no_open': True}" />
|
||||
</page>
|
||||
</notebook>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
# © 2021 Florian Kantelberg - initOS GmbH
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
||||
|
||||
from . import (
|
||||
vault_export_wizard,
|
||||
vault_import_wizard,
|
||||
vault_send_wizard,
|
||||
vault_store_wizard,
|
||||
)
|
||||
|
|
@ -0,0 +1,71 @@
|
|||
# © 2021 Florian Kantelberg - initOS GmbH
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
from odoo import _, api, fields, models
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ExportWizard(models.TransientModel):
|
||||
_name = "vault.export.wizard"
|
||||
_description = _("Export wizard for vaults")
|
||||
|
||||
vault_id = fields.Many2one("vault", "Vault")
|
||||
entry_id = fields.Many2one(
|
||||
"vault.entry", "Entries", domain="[('vault_id', '=', vault_id)]"
|
||||
)
|
||||
master_key = fields.Char(related="vault_id.master_key")
|
||||
name = fields.Char(default=lambda self: self._default_name())
|
||||
content = fields.Binary("Download", attachment=False)
|
||||
include_childs = fields.Boolean(default=True)
|
||||
|
||||
@api.onchange("vault_id", "entry_id")
|
||||
def _onchange_content(self):
|
||||
for rec in self.with_context(skip_log=True):
|
||||
rec.content = self._export_content(
|
||||
rec.vault_id,
|
||||
rec.entry_id,
|
||||
rec.include_childs,
|
||||
)
|
||||
|
||||
def _default_name(self):
|
||||
return datetime.now().strftime("database-%Y%m%d-%H%M.json")
|
||||
|
||||
def _export_content(self, vault=None, entry=None, include_childs=False):
|
||||
if entry:
|
||||
entries = entry
|
||||
elif vault:
|
||||
entries = vault.entry_ids.filtered_domain([("parent_id", "=", False)])
|
||||
else:
|
||||
return json.dumps([])
|
||||
|
||||
data = [self._export_entry(x, include_childs) for x in entries]
|
||||
return json.dumps(data)
|
||||
|
||||
@api.model
|
||||
def _export_field(self, rec):
|
||||
def ensure_string(x):
|
||||
return x.decode() if isinstance(x, bytes) else x
|
||||
|
||||
return {f: ensure_string(rec[f]) for f in ["name", "iv", "value"]}
|
||||
|
||||
@api.model
|
||||
def _export_entry(self, entry, include_childs=False):
|
||||
if include_childs:
|
||||
childs = [self._export_entry(x, include_childs) for x in entry.child_ids]
|
||||
else:
|
||||
childs = []
|
||||
|
||||
return {
|
||||
"uuid": entry.uuid,
|
||||
"name": entry.name,
|
||||
"note": entry.note,
|
||||
"url": entry.url,
|
||||
"fields": entry.field_ids.mapped(self._export_field),
|
||||
"files": entry.file_ids.mapped(self._export_field),
|
||||
"childs": childs,
|
||||
}
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<odoo>
|
||||
<record id="view_vault_export_wizard" model="ir.ui.view">
|
||||
<field name="model">vault.export.wizard</field>
|
||||
<field name="arch" type="xml">
|
||||
<form>
|
||||
<sheet>
|
||||
<field name="vault_id" invisible="1" />
|
||||
<field name="entry_id" invisible="1" />
|
||||
<field name="master_key" invisible="1" />
|
||||
<field name="name" invisible="1" />
|
||||
|
||||
<group>
|
||||
<field
|
||||
name="content"
|
||||
widget="vault_export_file"
|
||||
filename="name"
|
||||
readonly="1"
|
||||
/>
|
||||
<field name="include_childs" />
|
||||
</group>
|
||||
</sheet>
|
||||
<footer>
|
||||
<button type="special" string="Close" special="cancel" />
|
||||
</footer>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
|
|
@ -0,0 +1,134 @@
|
|||
# © 2021 Florian Kantelberg - initOS GmbH
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
||||
|
||||
import json
|
||||
import logging
|
||||
from uuid import uuid4
|
||||
|
||||
from odoo import _, api, fields, models
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ImportWizardPath(models.TransientModel):
|
||||
_name = "vault.import.wizard.path"
|
||||
_description = _("Import wizard path for vaults")
|
||||
|
||||
name = fields.Char(required=True)
|
||||
uuid = fields.Char(required=True)
|
||||
|
||||
|
||||
class ImportWizard(models.TransientModel):
|
||||
_name = "vault.import.wizard"
|
||||
_description = _("Import wizard for vaults")
|
||||
|
||||
vault_id = fields.Many2one("vault", "Vault")
|
||||
parent_id = fields.Many2one(
|
||||
"vault.entry",
|
||||
"Parent Entry",
|
||||
domain="[('vault_id', '=', vault_id)]",
|
||||
)
|
||||
master_key = fields.Char(related="vault_id.master_key")
|
||||
name = fields.Char()
|
||||
content = fields.Binary("Database", attachment=False)
|
||||
crypted_content = fields.Char()
|
||||
uuid = fields.Char(default=lambda self: uuid4())
|
||||
|
||||
path = fields.Many2one(
|
||||
"vault.import.wizard.path",
|
||||
"Path to import",
|
||||
default="",
|
||||
domain="[('uuid', '=', uuid)]",
|
||||
)
|
||||
|
||||
@api.onchange("crypted_content", "content")
|
||||
def _onchange_content(self):
|
||||
for rec in self:
|
||||
if rec.crypted_content:
|
||||
for entry in json.loads(rec.crypted_content or []):
|
||||
rec._create_path(entry)
|
||||
|
||||
def _create_path(self, entry, path=None):
|
||||
self.ensure_one()
|
||||
p = f"{path} / {entry['name']}" if path else entry["name"]
|
||||
|
||||
if "name" in entry:
|
||||
self.env["vault.import.wizard.path"].create({"uuid": self.uuid, "name": p})
|
||||
|
||||
for child in entry.get("childs", []):
|
||||
self._create_path(child, p)
|
||||
|
||||
def _import_field(self, entry, model, data):
|
||||
if not data:
|
||||
return
|
||||
|
||||
# Only copy specific fields
|
||||
vals = {f: data[f] for f in ["name", "iv", "value"]}
|
||||
|
||||
# Update already existing records
|
||||
domain = [("entry_id", "=", entry.id), ("name", "=", data["name"])]
|
||||
rec = model.search(domain)
|
||||
if rec:
|
||||
rec.write(vals)
|
||||
else:
|
||||
rec.create({"entry_id": entry.id, **vals})
|
||||
|
||||
def _import_entry(self, entry, parent=None, path=None):
|
||||
p = f"{path} / {entry['name']}" if path else entry["name"]
|
||||
result = self.env["vault.entry"]
|
||||
if p.startswith(self.path.name or ""):
|
||||
if not parent:
|
||||
parent = self.env["vault.entry"]
|
||||
|
||||
# Update existing records if already imported
|
||||
rec = self.env["vault.entry"]
|
||||
if entry.get("uuid"):
|
||||
domain = [
|
||||
("uuid", "=", entry["uuid"]),
|
||||
("vault_id", "=", self.vault_id.id),
|
||||
]
|
||||
rec = rec.search(domain, limit=1)
|
||||
|
||||
# If record not found create a new one
|
||||
vals = {f: entry.get(f) for f in ["name", "note", "url", "uuid"]}
|
||||
if not rec:
|
||||
rec = rec.create(
|
||||
{"vault_id": self.vault_id.id, "parent_id": parent.id, **vals}
|
||||
)
|
||||
else:
|
||||
rec.write({"parent_id": parent.id, **vals})
|
||||
|
||||
# Create/update the entry fields
|
||||
for field in entry.get("fields", []):
|
||||
self._import_field(rec, self.env["vault.field"], field)
|
||||
|
||||
# Create/update the entry files
|
||||
for file in entry.get("files", []):
|
||||
self._import_field(rec, self.env["vault.file"], file)
|
||||
|
||||
result |= rec
|
||||
|
||||
else:
|
||||
rec = None
|
||||
|
||||
# Create the sub-entries
|
||||
for child in entry.get("childs", []):
|
||||
result |= self._import_entry(child, rec, p)
|
||||
|
||||
return result
|
||||
|
||||
def action_import(self):
|
||||
self.ensure_one()
|
||||
|
||||
try:
|
||||
data = json.loads(self.crypted_content)
|
||||
entries = self.env["vault.entry"]
|
||||
for entry in data:
|
||||
entries |= self.with_context(skip_log=True)._import_entry(
|
||||
entry, self.parent_id
|
||||
)
|
||||
|
||||
self.vault_id.log_entry(f"Imported entries from file {self.name}")
|
||||
except Exception as e:
|
||||
raise UserError(_("Invalid file to import from")) from e
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<odoo>
|
||||
<record id="view_vault_import_wizard" model="ir.ui.view">
|
||||
<field name="model">vault.import.wizard</field>
|
||||
<field name="arch" type="xml">
|
||||
<form>
|
||||
<sheet>
|
||||
<field name="master_key" invisible="1" />
|
||||
<field name="vault_id" invisible="1" />
|
||||
<field name="crypted_content" invisible="1" />
|
||||
<field name="name" invisible="1" />
|
||||
<field name="uuid" invisible="1" />
|
||||
|
||||
<div>The files must end on one of the supported file type:</div>
|
||||
<ul>
|
||||
<li>Custom JSON format <b>.json</b></li>
|
||||
<li>Keepass Database <b>.kdbx</b></li>
|
||||
</ul>
|
||||
|
||||
<group>
|
||||
<field name="content" filename="name" />
|
||||
<field
|
||||
name="parent_id"
|
||||
options="{'no_create_edit': True, 'no_open': True}"
|
||||
/>
|
||||
<field
|
||||
name="path"
|
||||
attrs="{'invisible': [('crypted_content', '=', False)]}"
|
||||
options="{'no_create_edit': True, 'no_open': True}"
|
||||
/>
|
||||
</group>
|
||||
</sheet>
|
||||
<footer>
|
||||
<button
|
||||
type="object"
|
||||
name="action_import"
|
||||
string="Import"
|
||||
class="oe_highlight"
|
||||
attrs="{'invisible': [('crypted_content', '=', False)]}"
|
||||
/>
|
||||
<button
|
||||
type="object"
|
||||
name="action_import"
|
||||
string="Import"
|
||||
attrs="{'invisible': [('crypted_content', '!=', False)]}"
|
||||
/>
|
||||
or
|
||||
<button type="special" string="Cancel" special="cancel" />
|
||||
</footer>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
# © 2021 Florian Kantelberg - initOS GmbH
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
||||
|
||||
import logging
|
||||
|
||||
from odoo import _, fields, models
|
||||
from odoo.exceptions import ValidationError
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class VaultSendWizard(models.TransientModel):
|
||||
_name = "vault.send.wizard"
|
||||
_description = _("Wizard to send another user a secret")
|
||||
|
||||
user_id = fields.Many2one(
|
||||
"res.users",
|
||||
"User",
|
||||
required=True,
|
||||
domain=[("keys", "!=", False), ("inbox_enabled", "=", True)],
|
||||
)
|
||||
name = fields.Char(required=True)
|
||||
public = fields.Char(related="user_id.active_key.public")
|
||||
iv = fields.Char(required=True)
|
||||
key_user = fields.Char(required=True)
|
||||
key = fields.Char(required=True)
|
||||
secret = fields.Char()
|
||||
secret_file = fields.Char()
|
||||
filename = fields.Char()
|
||||
|
||||
_sql_constraints = [
|
||||
(
|
||||
"value_check",
|
||||
"CHECK(secret IS NOT NULL OR secret_file IS NOT NULL)",
|
||||
_("No value found"),
|
||||
),
|
||||
]
|
||||
|
||||
def action_send(self):
|
||||
if not self.secret and not self.secret_file:
|
||||
raise ValidationError(_("Neither a secret nor file was given"))
|
||||
|
||||
self.ensure_one()
|
||||
self.env["vault.inbox"].sudo().create(
|
||||
{
|
||||
"name": self.name,
|
||||
"accesses": 0,
|
||||
"secret": self.secret,
|
||||
"secret_file": self.secret_file,
|
||||
"iv": self.iv,
|
||||
"key": self.key_user,
|
||||
"user_id": self.user_id.id,
|
||||
"filename": self.filename,
|
||||
"log_ids": [(0, 0, {"name": _("Created by %s") % self.user_id.name})],
|
||||
}
|
||||
)
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<odoo>
|
||||
<record id="view_vault_send_wizard" model="ir.ui.view">
|
||||
<field name="model">vault.send.wizard</field>
|
||||
<field name="arch" type="xml">
|
||||
<form>
|
||||
<sheet>
|
||||
<div>
|
||||
You can only send the secret to the user who has generated a key-pair.
|
||||
If an user is not showing please ask him to generate these.
|
||||
</div>
|
||||
<group>
|
||||
<field name="iv" invisible="1" />
|
||||
<field name="key" invisible="1" />
|
||||
<field name="key_user" invisible="1" />
|
||||
<field name="secret" invisible="1" />
|
||||
<field name="public" invisible="1" />
|
||||
<field
|
||||
name="user_id"
|
||||
options="{'no_open': true, 'no_create_edit': true, 'no_quick_create': true}"
|
||||
/>
|
||||
<field name="name" />
|
||||
</group>
|
||||
</sheet>
|
||||
<footer>
|
||||
<button
|
||||
type="object"
|
||||
name="action_send"
|
||||
string="Send"
|
||||
class="oe_highlight"
|
||||
/>
|
||||
or
|
||||
<button type="special" string="Cancel" special="cancel" />
|
||||
</footer>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
# © 2021 Florian Kantelberg - initOS GmbH
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
||||
|
||||
import logging
|
||||
|
||||
from odoo import _, api, fields, models
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class VaultStoreWizard(models.TransientModel):
|
||||
_name = "vault.store.wizard"
|
||||
_description = _("Wizard store a shared secret in a vault")
|
||||
|
||||
vault_id = fields.Many2one("vault", "Vault", required=True)
|
||||
entry_id = fields.Many2one(
|
||||
"vault.entry",
|
||||
"Entry",
|
||||
domain="[('vault_id', '=', vault_id)]",
|
||||
required=True,
|
||||
)
|
||||
model = fields.Char(required=True)
|
||||
master_key = fields.Char(compute="_compute_master_key", store=False)
|
||||
name = fields.Char(required=True)
|
||||
iv = fields.Char(required=True)
|
||||
key = fields.Char(required=True)
|
||||
secret = fields.Char(required=True)
|
||||
secret_temporary = fields.Char(required=True)
|
||||
|
||||
@api.depends("entry_id", "vault_id")
|
||||
def _compute_master_key(self):
|
||||
for rec in self:
|
||||
rec.master_key = rec.vault_id.master_key
|
||||
|
||||
def action_store(self):
|
||||
self.ensure_one()
|
||||
try:
|
||||
self.env[self.model].create(
|
||||
{
|
||||
"entry_id": self.entry_id.id,
|
||||
"name": self.name,
|
||||
"iv": self.iv,
|
||||
"value": self.secret,
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
_logger.exception(e)
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<odoo>
|
||||
<record id="view_vault_store_wizard" model="ir.ui.view">
|
||||
<field name="model">vault.store.wizard</field>
|
||||
<field name="arch" type="xml">
|
||||
<form>
|
||||
<sheet>
|
||||
<group>
|
||||
<field name="iv" invisible="1" />
|
||||
<field name="key" invisible="1" />
|
||||
<field name="master_key" invisible="1" />
|
||||
<field name="secret" invisible="1" />
|
||||
<field name="secret_temporary" invisible="1" />
|
||||
<field
|
||||
name="vault_id"
|
||||
options="{'no_create_edit': True, 'no_open': True, 'no_quick_create': true}"
|
||||
/>
|
||||
<field
|
||||
name="entry_id"
|
||||
attrs="{'invisible': [('vault_id', '=', False)]}"
|
||||
options="{'no_create_edit': True, 'no_open': True, 'no_quick_create': true}"
|
||||
/>
|
||||
<field name="name" />
|
||||
</group>
|
||||
</sheet>
|
||||
<footer>
|
||||
<button
|
||||
type="object"
|
||||
name="action_store"
|
||||
string="Store"
|
||||
class="oe_highlight"
|
||||
/>
|
||||
or
|
||||
<button type="special" string="Cancel" special="cancel" />
|
||||
</footer>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
Loading…
Add table
Add a link
Reference in a new issue