From d1a8d87abfc00dd161c2067b959367a7bb27fcb8 Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Wed, 10 Jun 2026 11:25:37 +0800 Subject: [PATCH 01/14] feat(spp_pii_encryption): port PII encryption core from openspp-modules Migrate the field-level PII encryption module to OpenSPP2 (Odoo 19): - spp.encrypted.field.mixin: transparent AES-256-GCM encryption with HMAC-SHA256 blind indexes (exact/partial/phonetic) for searchability - spp.field.encryption.config: UI-based per-field encryption config - spp.pii.audit.log: audit trail of PII field access - masked_char OWL widget for masked display with reveal + audit Integrates with spp_key_management's key manager (get_key/get_salt). Depends on base, spp_key_management, spp_registry, spp_security. The bulk-migration wizard is intentionally deferred to a follow-up PR: it depends on spp_data_classification, which is not yet migrated to OpenSPP2. Realises ADR-011/ADR-012. Verified: 7/7 mixin tests pass, module installs in full UI stack, backend asset bundle compiles with the masked widget included. --- spp_pii_encryption/README.rst | 157 ++++++ spp_pii_encryption/__init__.py | 2 + spp_pii_encryption/__manifest__.py | 43 ++ spp_pii_encryption/models/__init__.py | 5 + spp_pii_encryption/models/audit_log.py | 164 ++++++ .../models/encrypted_field_mixin.py | 457 +++++++++++++++ .../models/field_encryption_config.py | 222 ++++++++ spp_pii_encryption/pyproject.toml | 3 + spp_pii_encryption/readme/DESCRIPTION.md | 53 ++ .../security/ir.model.access.csv | 5 + .../security/security_groups.xml | 21 + .../static/description/icon.png | Bin 0 -> 15480 bytes .../static/description/index.html | 531 ++++++++++++++++++ .../static/src/js/masked_field.js | 173 ++++++ .../static/src/scss/masked_field.scss | 36 ++ .../static/src/xml/masked_field.xml | 40 ++ spp_pii_encryption/tests/__init__.py | 3 + .../tests/test_encrypted_field_mixin.py | 128 +++++ spp_pii_encryption/views/audit_log_views.xml | 142 +++++ .../views/field_encryption_config_views.xml | 198 +++++++ spp_pii_encryption/views/menu.xml | 29 + 21 files changed, 2412 insertions(+) create mode 100644 spp_pii_encryption/README.rst create mode 100644 spp_pii_encryption/__init__.py create mode 100644 spp_pii_encryption/__manifest__.py create mode 100644 spp_pii_encryption/models/__init__.py create mode 100644 spp_pii_encryption/models/audit_log.py create mode 100644 spp_pii_encryption/models/encrypted_field_mixin.py create mode 100644 spp_pii_encryption/models/field_encryption_config.py create mode 100644 spp_pii_encryption/pyproject.toml create mode 100644 spp_pii_encryption/readme/DESCRIPTION.md create mode 100644 spp_pii_encryption/security/ir.model.access.csv create mode 100644 spp_pii_encryption/security/security_groups.xml create mode 100644 spp_pii_encryption/static/description/icon.png create mode 100644 spp_pii_encryption/static/description/index.html create mode 100644 spp_pii_encryption/static/src/js/masked_field.js create mode 100644 spp_pii_encryption/static/src/scss/masked_field.scss create mode 100644 spp_pii_encryption/static/src/xml/masked_field.xml create mode 100644 spp_pii_encryption/tests/__init__.py create mode 100644 spp_pii_encryption/tests/test_encrypted_field_mixin.py create mode 100644 spp_pii_encryption/views/audit_log_views.xml create mode 100644 spp_pii_encryption/views/field_encryption_config_views.xml create mode 100644 spp_pii_encryption/views/menu.xml diff --git a/spp_pii_encryption/README.rst b/spp_pii_encryption/README.rst new file mode 100644 index 000000000..6f1645150 --- /dev/null +++ b/spp_pii_encryption/README.rst @@ -0,0 +1,157 @@ +====================== +OpenSPP PII Encryption +====================== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:8e2f68c7ce4e6618450ce30c2d60928afe8e918a3f7a91e389a7dd09a0cdf6f5 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Alpha-red.png + :target: https://odoo-community.org/page/development-status + :alt: Alpha +.. |badge2| image:: https://img.shields.io/badge/license-LGPL--3-blue.png + :target: http://www.gnu.org/licenses/lgpl-3.0-standalone.html + :alt: License: LGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OpenSPP%2FOpenSPP2-lightgray.png?logo=github + :target: https://github.com/OpenSPP/OpenSPP2/tree/19.0/spp_pii_encryption + :alt: OpenSPP/OpenSPP2 + +|badge1| |badge2| |badge3| + +Field-level encryption for PII data using AES-256-GCM with searchable +blind indexes. Provides transparent encryption/decryption through a +mixin, UI-based configuration of which fields to encrypt, and audit +logging of PII field access. + +Key Capabilities +~~~~~~~~~~~~~~~~ + +- Encrypt char/text fields transparently using AES-256-GCM authenticated + encryption +- Search encrypted data via blind indexes without decryption (exact, + partial, or phonetic matching) +- Configure field encryption through UI instead of code changes +- Audit all PII field access (reveal, export, decrypt, modify, delete) + with IP and user agent tracking + +Key Models +~~~~~~~~~~ + ++---------------------------------+------------------------------------+ +| Model | Description | ++=================================+====================================+ +| ``spp.encrypted.field.mixin`` | Abstract mixin for models with | +| | encrypted PII fields | ++---------------------------------+------------------------------------+ +| ``spp.field.encryption.config`` | UI-based configuration for | +| | enabling encryption on specific | +| | fields | ++---------------------------------+------------------------------------+ +| ``spp.pii.audit.log`` | Audit log of PII field access | +| | events | ++---------------------------------+------------------------------------+ + +Configuration +~~~~~~~~~~~~~ + +After installing: + +1. Navigate to **Settings > Key Management > PII Encryption > Field + Configuration** +2. Create a new configuration selecting the model and field to encrypt +3. Choose the blind index type: Exact (full normalized match), Partial + (last 4 characters), or Phonetic (Soundex for names) +4. Enable encryption and blind index options + +Bulk migration of existing plaintext data (scan, dry-run, backup, +rollback) is provided separately and depends on the data classification +module. + +UI Location +~~~~~~~~~~~ + +- **Configuration**: Settings > Key Management > PII Encryption > Field + Configuration +- **Audit Log**: Settings > Key Management > PII Encryption > Audit Log + +Security +~~~~~~~~ + ++-----------------------------------------------+----------------------------------+ +| Group | Access | ++===============================================+==================================+ +| ``spp_pii_encryption.group_encryption_admin`` | Full CRUD on field | +| | configuration; Read on audit log | ++-----------------------------------------------+----------------------------------+ +| ``base.group_system`` | Read/Create on audit logs | ++-----------------------------------------------+----------------------------------+ +| ``base.group_user`` | Read field encryption | +| | configuration | ++-----------------------------------------------+----------------------------------+ + +Extension Points +~~~~~~~~~~~~~~~~ + +- Inherit from ``spp.encrypted.field.mixin`` on any model with PII + fields +- Implement ``_get_encrypted_fields()`` to specify which fields to + encrypt (or configure via UI) +- Override ``_get_encryption_key(field_name)`` to customize key + retrieval per field +- Override ``_normalize_for_index(value, index_type)`` to customize + blind index normalization +- Use ``search_by_blind_index(field_name, search_value)`` to search + encrypted fields +- Call ``log_field_access(model, record_id, field, action, reason)`` to + audit PII access + +Dependencies +~~~~~~~~~~~~ + +``base``, ``spp_key_management``, ``spp_registry``, ``spp_security`` + +.. IMPORTANT:: + This is an alpha version, the data model and design can change at any time without warning. + Only for development or testing purpose, do not use in production. + +**Table of contents** + +.. contents:: + :local: + +Bug Tracker +=========== + +Bugs are tracked on `GitHub 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 `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +------- + +* OpenSPP.org + +Maintainers +----------- + +.. |maintainer-gonzalesedwin1123| image:: https://github.com/gonzalesedwin1123.png?size=40px + :target: https://github.com/gonzalesedwin1123 + :alt: gonzalesedwin1123 + +Current maintainer: + +|maintainer-gonzalesedwin1123| + +This module is part of the `OpenSPP/OpenSPP2 `_ project on GitHub. + +You are welcome to contribute. \ No newline at end of file diff --git a/spp_pii_encryption/__init__.py b/spp_pii_encryption/__init__.py new file mode 100644 index 000000000..d33610325 --- /dev/null +++ b/spp_pii_encryption/__init__.py @@ -0,0 +1,2 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. +from . import models diff --git a/spp_pii_encryption/__manifest__.py b/spp_pii_encryption/__manifest__.py new file mode 100644 index 000000000..8d0dc3206 --- /dev/null +++ b/spp_pii_encryption/__manifest__.py @@ -0,0 +1,43 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. +{ + "name": "OpenSPP PII Encryption", + "summary": "Field-level encryption for PII data with searchable blind indexes", + "category": "OpenSPP/Configuration", + "version": "19.0.1.0.0", + "sequence": 1, + "author": "OpenSPP.org", + "website": "https://github.com/OpenSPP/OpenSPP2", + "license": "LGPL-3", + "development_status": "Alpha", + "maintainers": ["jeremi", "gonzalesedwin1123"], + "depends": [ + "base", + "spp_key_management", # Centralized key management + "spp_registry", + "spp_security", + ], + "external_dependencies": { + "python": [ + "cryptography", + ], + }, + "data": [ + "security/security_groups.xml", + "security/ir.model.access.csv", + "views/audit_log_views.xml", + "views/field_encryption_config_views.xml", + "views/menu.xml", + ], + "assets": { + "web.assets_backend": [ + "spp_pii_encryption/static/src/js/masked_field.js", + "spp_pii_encryption/static/src/xml/masked_field.xml", + "spp_pii_encryption/static/src/scss/masked_field.scss", + ], + }, + "demo": [], + "images": [], + "application": False, + "installable": True, + "auto_install": False, +} diff --git a/spp_pii_encryption/models/__init__.py b/spp_pii_encryption/models/__init__.py new file mode 100644 index 000000000..b680867ef --- /dev/null +++ b/spp_pii_encryption/models/__init__.py @@ -0,0 +1,5 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. +# Key management is now handled by spp_key_management module +from . import encrypted_field_mixin +from . import audit_log +from . import field_encryption_config diff --git a/spp_pii_encryption/models/audit_log.py b/spp_pii_encryption/models/audit_log.py new file mode 100644 index 000000000..395709463 --- /dev/null +++ b/spp_pii_encryption/models/audit_log.py @@ -0,0 +1,164 @@ +import logging + +from odoo import api, fields, models + +_logger = logging.getLogger(__name__) + + +class PIIAuditLog(models.Model): + """Audit log for PII field access and modifications.""" + + _name = "spp.pii.audit.log" + _description = "PII Audit Log" + _order = "create_date desc" + _rec_name = "display_name" + + model_name = fields.Char( + string="Model", + required=True, + readonly=True, + index=True, + ) + record_id = fields.Integer( + string="Record ID", + required=True, + readonly=True, + index=True, + ) + field_name = fields.Char( + string="Field", + required=True, + readonly=True, + index=True, + ) + action = fields.Selection( + selection=[ + ("reveal", "Revealed"), + ("export", "Exported"), + ("decrypt", "Decrypted"), + ("modify", "Modified"), + ("delete", "Deleted"), + ], + string="Action", + required=True, + readonly=True, + index=True, + ) + user_id = fields.Many2one( + comodel_name="res.users", + string="User", + required=True, + readonly=True, + default=lambda self: self.env.user.id, + index=True, + ) + ip_address = fields.Char( + string="IP Address", + readonly=True, + ) + user_agent = fields.Char( + string="User Agent", + readonly=True, + ) + reason = fields.Text( + string="Reason", + readonly=True, + ) + + display_name = fields.Char( + compute="_compute_display_name", + store=True, + ) + + @api.depends("model_name", "field_name", "action") + def _compute_display_name(self): + for record in self: + record.display_name = f"{record.action} {record.model_name}.{record.field_name}" + + @api.model + def log_field_access(self, model_name, record_id, field_name, action, reason=None): + """Log a PII field access event. + + Args: + model_name: The model being accessed + record_id: The record ID being accessed + field_name: The field being accessed + action: The type of access (reveal, export, decrypt, modify, delete) + reason: Optional reason for the access + + Returns: + The created audit log record + """ + # Get IP and user agent from request if available + ip_address = None + user_agent = None + + try: + from odoo.http import request + + if request: + ip_address = request.httprequest.remote_addr + user_agent = request.httprequest.user_agent.string[:500] if request.httprequest.user_agent else None + except Exception: + pass + + # Always log with sudo() so PII access audit trails are recorded + # even if the caller has limited write access on the audit model. + return self.sudo().create( # nosemgrep: odoo-sudo-without-context - System-level PII access audit log creation. + { + "model_name": model_name, + "record_id": record_id, + "field_name": field_name, + "action": action, + "ip_address": ip_address, + "user_agent": user_agent, + "reason": reason, + } + ) + + @api.model + def get_access_history(self, model_name, record_id, limit=100): + """Get access history for a specific record. + + Args: + model_name: The model name + record_id: The record ID + limit: Maximum number of records to return + + Returns: + Recordset of audit log entries + """ + return self.search( + [ + ("model_name", "=", model_name), + ("record_id", "=", record_id), + ], + limit=limit, + ) + + @api.model + def get_user_access_history(self, user_id=None, days=30, limit=1000): + """Get access history for a user. + + Args: + user_id: The user ID (defaults to current user) + days: Number of days to look back + limit: Maximum number of records to return + + Returns: + Recordset of audit log entries + """ + from datetime import datetime, timedelta + + if user_id is None: + user_id = self.env.user.id + + cutoff = datetime.now() - timedelta(days=days) + + return self.search( + [ + ("user_id", "=", user_id), + ("create_date", ">=", cutoff.strftime("%Y-%m-%d %H:%M:%S")), + ], + limit=limit, + ) diff --git a/spp_pii_encryption/models/encrypted_field_mixin.py b/spp_pii_encryption/models/encrypted_field_mixin.py new file mode 100644 index 000000000..b48474416 --- /dev/null +++ b/spp_pii_encryption/models/encrypted_field_mixin.py @@ -0,0 +1,457 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. +""" +Encrypted Field Mixin. + +Provides transparent encryption/decryption for PII fields with +searchable blind indexes. + +Usage: + class RegistryID(models.Model): + _name = "spp.registry.id" + _inherit = ["spp.registry.id", "spp.encrypted.field.mixin"] + + # Original field stores encrypted data + value = fields.Char() + + def _get_encrypted_fields(self): + return ['value'] + +The mixin automatically: +- Encrypts values before write +- Decrypts values after read +- Maintains blind indexes for searching +- Logs access to encrypted fields +""" + +import base64 +import hashlib +import hmac +import logging +import secrets + +from cryptography.hazmat.primitives.ciphers.aead import AESGCM + +from odoo import api, fields, models + +_logger = logging.getLogger(__name__) + + +class EncryptedFieldMixin(models.AbstractModel): + """Mixin for models with encrypted PII fields. + + Provides transparent encryption with searchable blind indexes. + + Models using this mixin should: + 1. Inherit from this mixin + 2. Implement _get_encrypted_fields() to list encrypted fields + 3. Optionally add index fields (e.g., value_index, value_last4) + + Example: + class RegistryID(models.Model): + _name = "spp.registry.id" + _inherit = ["spp.registry.id", "spp.encrypted.field.mixin"] + + value = fields.Char() # Will be encrypted + value_index = fields.Char(index=True) # Blind index + value_last4 = fields.Char(index=True) # Partial index + + def _get_encrypted_fields(self): + return ['value'] + """ + + _name = "spp.encrypted.field.mixin" + _description = "Encrypted Field Mixin" + + # Track which fields are currently encrypted (for migration) + _encryption_enabled = fields.Boolean( + default=True, + help="Whether encryption is enabled for this record", + ) + + def _get_encrypted_fields(self): + """Return list of field names that should be encrypted. + + Checks database configuration first, then falls back to code override. + This allows UI-based configuration via spp.field.encryption.config. + + Returns: + list: Field names to encrypt + """ + # Check database configuration + FieldConfig = self.env.get("spp.field.encryption.config") + if FieldConfig: + db_fields = FieldConfig.get_encrypted_fields(self._name) + if db_fields: + return db_fields + # Fallback: no encryption configured + return [] + + def _get_key_manager(self): + """Get the centralized key manager. + + Returns: + spp.key.manager instance + """ + return self.env["spp.key.manager"] + + def _get_encryption_key(self, field_name): + """Get encryption key for a field. + + Args: + field_name: The field being encrypted + + Returns: + bytes: The encryption key + """ + key_manager = self._get_key_manager() + # Use 'pii' purpose for all PII fields + # Could be extended to use different keys per classification + return key_manager.get_key("pii", "pii") + + def _get_index_salt(self, field_name): + """Get salt for blind index of a field. + + Args: + field_name: The field name + + Returns: + bytes: The salt + """ + key_manager = self._get_key_manager() + return key_manager.get_salt("pii", field_name) + + def _get_index_type(self, field_name): + """Get configured index type for a field. + + Args: + field_name: The field name + + Returns: + str: Index type ('exact', 'partial', 'phonetic') or 'exact' as default + """ + FieldConfig = self.env.get("spp.field.encryption.config") + if FieldConfig: + config_type = FieldConfig.get_index_type(self._name, field_name) + if config_type: + return config_type + return "exact" + + def _encrypt_value(self, value, field_name): + """Encrypt a field value. + + Uses AES-256-GCM for authenticated encryption. + + Args: + value: The plaintext value + field_name: The field being encrypted (used as AAD) + + Returns: + str: Base64-encoded encrypted value (nonce + ciphertext + tag) + """ + if not value: + return value + + try: + key = self._get_encryption_key(field_name) + aesgcm = AESGCM(key) + + # Use field name as additional authenticated data + aad = f"{self._name}.{field_name}".encode() + + # Generate random nonce + nonce = secrets.token_bytes(12) + + # Encrypt + plaintext = str(value).encode("utf-8") + ciphertext = aesgcm.encrypt(nonce, plaintext, aad) + + # Combine nonce + ciphertext and base64 encode + encrypted = base64.b64encode(nonce + ciphertext).decode("ascii") + return encrypted + + except Exception: + # SECURITY: Log error without crypto details that could aid attackers + _logger.error("Encryption failed for %s.%s (details suppressed for security)", self._name, field_name) + raise ValueError( + f"Encryption failed for field '{field_name}'. Check system configuration and logs." + ) from None + + def _decrypt_value(self, encrypted_value, field_name): + """Decrypt a field value. + + Args: + encrypted_value: Base64-encoded encrypted value + field_name: The field being decrypted + + Returns: + str: The plaintext value + """ + if not encrypted_value: + return encrypted_value + + try: + key = self._get_encryption_key(field_name) + aesgcm = AESGCM(key) + + # Use field name as additional authenticated data + aad = f"{self._name}.{field_name}".encode() + + # Decode and split nonce from ciphertext + data = base64.b64decode(encrypted_value) + nonce = data[:12] + ciphertext = data[12:] + + # Decrypt + plaintext = aesgcm.decrypt(nonce, ciphertext, aad) + return plaintext.decode("utf-8") + + except Exception: + # SECURITY: Log error without crypto details that could aid attackers + _logger.warning("Decryption failed for %s.%s (details suppressed for security)", self._name, field_name) + # Return None rather than failing - allows graceful handling + return None + + def _compute_blind_index(self, value, field_name, index_type="exact"): + """Compute searchable blind index for a value. + + Blind indexes allow searching encrypted data without + exposing the plaintext. + + Args: + value: The plaintext value + field_name: The field name + index_type: Type of index (exact, partial, phonetic) + + Returns: + str: The blind index (hex-encoded HMAC) + """ + if not value: + return None + + salt = self._get_index_salt(field_name) + normalized = self._normalize_for_index(value, index_type) + + # HMAC-SHA256 for deterministic but secure indexing + index = hmac.new( + salt, + normalized.encode("utf-8"), + hashlib.sha256, + ).hexdigest() + + return index + + def _normalize_for_index(self, value, index_type): + """Normalize value for consistent indexing. + + Args: + value: The value to normalize + index_type: Type of normalization + + Returns: + str: Normalized value + """ + import re + + value_str = str(value).strip() + + if index_type == "exact": + # Remove formatting, uppercase + return re.sub(r"[\s\-\.\(\)]", "", value_str).upper() + + elif index_type == "partial": + # Last 4 characters only (for partial matching) + normalized = re.sub(r"[\s\-\.\(\)]", "", value_str) + return normalized[-4:] if len(normalized) >= 4 else normalized + + elif index_type == "phonetic": + # Soundex for name matching + return self._soundex(value_str) + + return value_str + + def _soundex(self, name): + """Compute Soundex code for phonetic matching. + + Args: + name: The name to encode + + Returns: + str: 4-character Soundex code + """ + if not name: + return "0000" + + name = name.upper() + soundex = name[0] + + # Soundex mapping + mapping = { + "B": "1", + "F": "1", + "P": "1", + "V": "1", + "C": "2", + "G": "2", + "J": "2", + "K": "2", + "Q": "2", + "S": "2", + "X": "2", + "Z": "2", + "D": "3", + "T": "3", + "L": "4", + "M": "5", + "N": "5", + "R": "6", + } + + prev = mapping.get(name[0], "0") + for char in name[1:]: + code = mapping.get(char, "0") + if code != "0" and code != prev: + soundex += code + prev = code + + return (soundex + "0000")[:4] + + @api.model_create_multi + def create(self, vals_list): + """Encrypt fields before create.""" + encrypted_fields = self._get_encrypted_fields() + if not encrypted_fields: + return super().create(vals_list) + + for vals in vals_list: + for field_name in encrypted_fields: + if field_name in vals and vals[field_name]: + plaintext = vals[field_name] + + # Encrypt + vals[field_name] = self._encrypt_value(plaintext, field_name) + + # Get configured index type + index_type = self._get_index_type(field_name) + + # Compute blind indexes if fields exist + index_field = f"{field_name}_index" + if index_field in self._fields: + vals[index_field] = self._compute_blind_index(plaintext, field_name, index_type) + + last4_field = f"{field_name}_last4" + if last4_field in self._fields: + # SECURITY: Store hashed partial index, not plaintext + vals[last4_field] = self._compute_blind_index(plaintext, field_name, "partial") + + return super().create(vals_list) + + def write(self, vals): + """Encrypt fields before write.""" + encrypted_fields = self._get_encrypted_fields() + if not encrypted_fields: + return super().write(vals) + + for field_name in encrypted_fields: + if field_name in vals and vals[field_name]: + plaintext = vals[field_name] + + # Encrypt + vals[field_name] = self._encrypt_value(plaintext, field_name) + + # Get configured index type + index_type = self._get_index_type(field_name) + + # Compute blind indexes if fields exist + index_field = f"{field_name}_index" + if index_field in self._fields: + vals[index_field] = self._compute_blind_index(plaintext, field_name, index_type) + + last4_field = f"{field_name}_last4" + if last4_field in self._fields: + # SECURITY: Store hashed partial index, not plaintext + vals[last4_field] = self._compute_blind_index(plaintext, field_name, "partial") + + return super().write(vals) + + def read(self, fields_list=None, load="_classic_read"): + """Decrypt fields after read.""" + result = super().read(fields_list, load) + + encrypted_fields = self._get_encrypted_fields() + if not encrypted_fields: + return result + + # Determine which encrypted fields are being read + if fields_list: + fields_to_decrypt = [f for f in encrypted_fields if f in fields_list] + else: + fields_to_decrypt = encrypted_fields + + if not fields_to_decrypt: + return result + + # Decrypt values in result + for record_data in result: + for field_name in fields_to_decrypt: + if field_name in record_data and record_data[field_name]: + decrypted = self._decrypt_value(record_data[field_name], field_name) + if decrypted is not None: + record_data[field_name] = decrypted + else: + # Decryption failed - might be unencrypted data + # Leave as-is for backwards compatibility + pass + + return result + + def search_by_blind_index(self, field_name, search_value): + """Search encrypted field using blind index. + + Uses the configured index type for the field to ensure + consistent index computation between write and search. + + Args: + field_name: The encrypted field name + search_value: The plaintext value to search for + + Returns: + recordset: Matching records + """ + index_field = f"{field_name}_index" + if index_field not in self._fields: + _logger.warning( + "Blind index field '%s' not found on %s. Cannot search encrypted field.", + index_field, + self._name, + ) + return self.browse() + + # Use configured index type to match how the index was computed during write + index_type = self._get_index_type(field_name) + blind_index = self._compute_blind_index(search_value, field_name, index_type) + return self.search([(index_field, "=", blind_index)]) + + def search_by_partial(self, field_name, last_chars): + """Search encrypted field by last N characters. + + Uses blind index for secure partial matching. + + Args: + field_name: The encrypted field name + last_chars: The last characters to match + + Returns: + recordset: Matching records + """ + last4_field = f"{field_name}_last4" + if last4_field not in self._fields: + _logger.warning( + "Partial index field '%s' not found on %s.", + last4_field, + self._name, + ) + return self.browse() + + # SECURITY: Compute blind index for the search value + partial_index = self._compute_blind_index(last_chars, field_name, "partial") + return self.search([(last4_field, "=", partial_index)]) diff --git a/spp_pii_encryption/models/field_encryption_config.py b/spp_pii_encryption/models/field_encryption_config.py new file mode 100644 index 000000000..4ff88564e --- /dev/null +++ b/spp_pii_encryption/models/field_encryption_config.py @@ -0,0 +1,222 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. +""" +Field Encryption Configuration. + +Provides UI-based configuration for enabling/disabling encryption on +specific fields, along with blind index settings for searchable encryption. +""" + +import logging + +from odoo import _, api, fields, models +from odoo.exceptions import ValidationError + +_logger = logging.getLogger(__name__) + + +class FieldEncryptionConfig(models.Model): + """Configuration for field-level encryption. + + Allows administrators to enable encryption on specific fields via UI + instead of requiring code changes. + """ + + _name = "spp.field.encryption.config" + _description = "Field Encryption Configuration" + _order = "model_id, field_id" + _rec_name = "display_name" + + model_id = fields.Many2one( + comodel_name="ir.model", + string="Model", + required=True, + ondelete="cascade", + index=True, + help="The model containing the field to encrypt", + ) + model_name = fields.Char( + related="model_id.model", + store=True, + index=True, + ) + field_id = fields.Many2one( + comodel_name="ir.model.fields", + string="Field", + required=True, + ondelete="cascade", + index=True, + domain="[('model_id', '=', model_id), ('ttype', 'in', ['char', 'text'])]", + help="The field to encrypt (only char and text fields supported)", + ) + field_name = fields.Char( + related="field_id.name", + store=True, + index=True, + ) + + encryption_enabled = fields.Boolean( + string="Encryption Enabled", + default=True, + help="Enable encryption for this field", + ) + + blind_index_enabled = fields.Boolean( + string="Blind Index Enabled", + default=True, + help="Enable blind index for searching encrypted values", + ) + + index_type = fields.Selection( + selection=[ + ("exact", "Exact Match"), + ("partial", "Partial (Last 4)"), + ("phonetic", "Phonetic (Soundex)"), + ], + string="Index Type", + default="exact", + help="Type of blind index to use for searching:\n" + "- Exact: Full value matching (normalized)\n" + "- Partial: Match last 4 characters\n" + "- Phonetic: Sound-alike matching for names", + ) + + index_field_name = fields.Char( + string="Index Field", + compute="_compute_index_field_name", + help="Name of the field that stores the blind index", + ) + + display_name = fields.Char( + compute="_compute_display_name", + store=True, + ) + + active = fields.Boolean( + default=True, + help="Set to false to disable this configuration without deleting it", + ) + + notes = fields.Text( + string="Notes", + help="Optional notes about this encryption configuration", + ) + + # Unique constraint + _unique_model_field = models.Constraint( + "UNIQUE(model_id, field_id)", + "Encryption configuration already exists for this model and field!", + ) + + @api.depends("model_id", "field_id") + def _compute_display_name(self): + for record in self: + if record.model_id and record.field_id: + record.display_name = f"{record.model_id.model}.{record.field_id.name}" + else: + record.display_name = "New Configuration" + + @api.depends("field_id", "index_type") + def _compute_index_field_name(self): + for record in self: + if record.field_id: + if record.index_type == "partial": + record.index_field_name = f"{record.field_id.name}_last4" + else: + record.index_field_name = f"{record.field_id.name}_index" + else: + record.index_field_name = False + + @api.constrains("field_id") + def _check_field_type(self): + """Ensure only char and text fields can be encrypted.""" + for record in self: + if record.field_id and record.field_id.ttype not in ("char", "text"): + raise ValidationError( + _( + "Only Char and Text fields can be encrypted. '%(field)s' is a %(ttype)s field.", + field=record.field_id.name, + ttype=record.field_id.ttype, + ) + ) + + @api.model + def get_encrypted_fields(self, model_name): + """Get list of encrypted field names for a model. + + This method is called by the encrypted field mixin to determine + which fields should be encrypted. + + Args: + model_name: The model technical name (e.g., 'res.partner') + + Returns: + list: Field names that should be encrypted + """ + configs = self.search( + [ + ("model_name", "=", model_name), + ("encryption_enabled", "=", True), + ("active", "=", True), + ] + ) + return configs.mapped("field_name") + + @api.model + def get_field_config(self, model_name, field_name): + """Get encryption configuration for a specific field. + + Args: + model_name: The model technical name + field_name: The field name + + Returns: + spp.field.encryption.config record or empty recordset + """ + return self.search( + [ + ("model_name", "=", model_name), + ("field_name", "=", field_name), + ("active", "=", True), + ], + limit=1, + ) + + @api.model + def is_field_encrypted(self, model_name, field_name): + """Check if a field is configured for encryption. + + Args: + model_name: The model technical name + field_name: The field name + + Returns: + bool: True if field should be encrypted + """ + config = self.get_field_config(model_name, field_name) + return bool(config and config.encryption_enabled) + + @api.model + def get_index_type(self, model_name, field_name): + """Get the blind index type for a field. + + Args: + model_name: The model technical name + field_name: The field name + + Returns: + str: Index type ('exact', 'partial', 'phonetic') or None + """ + config = self.get_field_config(model_name, field_name) + if config and config.blind_index_enabled: + return config.index_type + return None + + def action_toggle_encryption(self): + """Toggle encryption for this field.""" + for record in self: + record.encryption_enabled = not record.encryption_enabled + + def action_toggle_blind_index(self): + """Toggle blind index for this field.""" + for record in self: + record.blind_index_enabled = not record.blind_index_enabled diff --git a/spp_pii_encryption/pyproject.toml b/spp_pii_encryption/pyproject.toml new file mode 100644 index 000000000..4231d0ccc --- /dev/null +++ b/spp_pii_encryption/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/spp_pii_encryption/readme/DESCRIPTION.md b/spp_pii_encryption/readme/DESCRIPTION.md new file mode 100644 index 000000000..4931908e5 --- /dev/null +++ b/spp_pii_encryption/readme/DESCRIPTION.md @@ -0,0 +1,53 @@ +Field-level encryption for PII data using AES-256-GCM with searchable blind indexes. Provides transparent encryption/decryption through a mixin, UI-based configuration of which fields to encrypt, and audit logging of PII field access. + +### Key Capabilities + +- Encrypt char/text fields transparently using AES-256-GCM authenticated encryption +- Search encrypted data via blind indexes without decryption (exact, partial, or phonetic matching) +- Configure field encryption through UI instead of code changes +- Audit all PII field access (reveal, export, decrypt, modify, delete) with IP and user agent tracking + +### Key Models + +| Model | Description | +| ------------------------------------- | ---------------------------------------------------------------- | +| `spp.encrypted.field.mixin` | Abstract mixin for models with encrypted PII fields | +| `spp.field.encryption.config` | UI-based configuration for enabling encryption on specific fields | +| `spp.pii.audit.log` | Audit log of PII field access events | + +### Configuration + +After installing: + +1. Navigate to **Settings > Key Management > PII Encryption > Field Configuration** +2. Create a new configuration selecting the model and field to encrypt +3. Choose the blind index type: Exact (full normalized match), Partial (last 4 characters), or Phonetic (Soundex for names) +4. Enable encryption and blind index options + +Bulk migration of existing plaintext data (scan, dry-run, backup, rollback) is provided separately and depends on the data classification module. + +### UI Location + +- **Configuration**: Settings > Key Management > PII Encryption > Field Configuration +- **Audit Log**: Settings > Key Management > PII Encryption > Audit Log + +### Security + +| Group | Access | +| -------------------------------------------- | -------------------------------------------------------------- | +| `spp_pii_encryption.group_encryption_admin` | Full CRUD on field configuration; Read on audit log | +| `base.group_system` | Read/Create on audit logs | +| `base.group_user` | Read field encryption configuration | + +### Extension Points + +- Inherit from `spp.encrypted.field.mixin` on any model with PII fields +- Implement `_get_encrypted_fields()` to specify which fields to encrypt (or configure via UI) +- Override `_get_encryption_key(field_name)` to customize key retrieval per field +- Override `_normalize_for_index(value, index_type)` to customize blind index normalization +- Use `search_by_blind_index(field_name, search_value)` to search encrypted fields +- Call `log_field_access(model, record_id, field, action, reason)` to audit PII access + +### Dependencies + +`base`, `spp_key_management`, `spp_registry`, `spp_security` diff --git a/spp_pii_encryption/security/ir.model.access.csv b/spp_pii_encryption/security/ir.model.access.csv new file mode 100644 index 000000000..14a3cff99 --- /dev/null +++ b/spp_pii_encryption/security/ir.model.access.csv @@ -0,0 +1,5 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_pii_audit_log_admin,PII Audit Log Admin,model_spp_pii_audit_log,group_encryption_admin,1,0,0,0 +access_pii_audit_log_system,PII Audit Log System,model_spp_pii_audit_log,base.group_system,1,0,1,0 +access_field_encryption_config_admin,Field Encryption Config Admin,model_spp_field_encryption_config,group_encryption_admin,1,1,1,1 +access_field_encryption_config_user,Field Encryption Config User,model_spp_field_encryption_config,base.group_user,1,0,0,0 diff --git a/spp_pii_encryption/security/security_groups.xml b/spp_pii_encryption/security/security_groups.xml new file mode 100644 index 000000000..0cec68e49 --- /dev/null +++ b/spp_pii_encryption/security/security_groups.xml @@ -0,0 +1,21 @@ + + + + + + + Administrator + + 10 + + + + + Encryption Administrator + + Can manage encryption keys and rotate them. Highly privileged role. + + + diff --git a/spp_pii_encryption/static/description/icon.png b/spp_pii_encryption/static/description/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..c7dbdaaf1dace8f0ccf8c2087047ddfcf584af0c GIT binary patch literal 15480 zcmbumbyQqU(=SR05Hz?GTnBdsm*BxQ_yEH|aCf%^cefzHo!|s_cXxLSE;*Cueee5y z-&tp!b=SRr%%17JtE+c)byrpYs^*)rqBI&Z5i$%644SOWM^)(ez~2ud0`yw0U6BR- zLb8+j><9z%zUS}fO(NraVi*{>9t(ACCvAmK{3f>6EFe=`V=#-GwH=fi21ZcC%?@N@ z33ehk216`tgy_y&+UdwGOoiyQxE0tG>?FYE7BU_VU^Nd#brTOu6QC)bh%mCC8$XnR zHP{J6?q+Red4B@!uI#I$jJr&Mb9s0>iD<$ zuR+wn_Wv~g)v~hqXCyn2gCkho-3}~7rwVqob#^cT|HI*Lr++h%Z~%jxz^1|+Y#iLo zY(Qpqpdjo2_UP{z|J6a#%}Lf&m<$tn~GKO;D=HTYw;RdpEvGW4C`Plx`;h%^9lV07{*~I*>D8d~7A^Wd; z|IiAu{+(Sbi+@eZKaGFS%71$NYs&sb_}|p>|6Wz5CjU{BowI}0KTE*WgcWQBwg%fc z{Z$hCzm;Ta!tZ3^WCi{&6^U6n{ZAD^*B-wW$Oa-r=f-RbHUl|ZInfDg*!P87jt$pw{;L! zurM(Pfvw2pY|U-RrEP6IKvrN!!N2tX4+V7f|D%KdPxB1jp8uKX|M5a@AiMvz6QE@L z|EyqJ2X$LpD`5$cjSGmJUKMO(3U&ZHFp!(tnh1RqllIVYQ3J`EJCZv)f*pi3#3YP4 zY;_;(mw~W(F*95)Y)WYoZkRgrLS)eSvJR)Y$S4!fK zScE24BMTw?G63}=yN?Nr!v4s(L#bh+ z0QHoB|LYajx?X9+TnwfJwuDj{M>z;4bu|DB7H;cherVEncj0{^h73csRh5-&U)E;4 zNLVpq{=h+rsFoNmYz*8AfN`m{D6C^2%WV~zRAFNZuAXKcKMErci*PnF0ZSfM)erUu zjcjUMJ_wuF3RSJ9O~@Z4hhap;#(_0ma`J>1A0~<{s?m|hcz{e!L&u6Tp}I}Ep<>4f zOJS|^MQ_DPOkz?*AhrH}k<9ZOEt4`FAyRDqXjTP|E_#oO27Gr&f`y5OM@B1VqH_ES zCTweSMCx}a*0xU}@o6fA8_gjjy z2Q57xXmg+m(g6q!aM8mCkithJ--tyXkCjku;FTF{?B>(>FABGzSGUggUumv`+C6Ow zvd1XmI~#j#dG0vl>e;QtxGX?gJsdQ+{-4BuDt%|kxthFj<_dORK@Rc;K*$U=E~?kF zJ$(-vwj?T<5%x2c(fneoKTjS|rpBh!8`&y_y)z)7Hj@j%)+~SkVR8K<@`g&WZjo&G z8?wNoqyeOzOEhl;E4C^_e6^7aF#Fx~(z-&NxzGQQC}?L?Gl>qxwKg;MZTpfMvw^V{ zmT;>h9A?JFxNyIC1IPqQldk82>?{LtnMt2Xo$HmXr3gvbffJCJF_|;ZU)lTX#2_{h zNT=4@taez10pm@hvzTLIAAD(`*Y6XZr7!w3a5sy>KWlOvJ92!fyI0Yjt7_+Syy+$Q z9i0@K!{?>N+F!J-sDJMIV zySlF4rF1c1>K1)CaHBkwkwVV z_lfaZhdgZH%&PK>eJxwrWn!sr5&Gc_9Cr|XDCGA_XN{>#)>Qgl3%Uyi`^M@mPTT`? zf;&`{13;P8O-+u@Hlr4IZO)ivM_w*HE{G3gydPIhU7gTd{}##Tw;S&&d-&?A1qaWy zLlnn3TyAMVFPcpfZ`1wMt^$+g?Z(_ki{MSWsfo#KTB33CzU=9qQnoXtdS(mcmLjCY zalOGBnh*x}*Hy&3cD8}2EUr+55qEqP9$UCvz=o=kb9%C^{(Ki9<6A_yTJAVGBAyn3 zIGGLv4!o55o*J5V_xfbsyPk=kC$C`%S6?3qh!N5V(<2M#9p=&i>al1cGc#6pd37`_ z3RMpN=*|e9{nd~zZKGX@%J-K$=_&@x#D$&<8NApJ?i3jM!5X8abIiAPla~}@BE@Ep zytt_iw|xY%OQxngqE(gy8xY@vUMZuc7&hw5I)$M+5$X^P z;i3S7-Tgw2w#pV1R->>O;O~UyyX#p3>DD8rfL3FNO@kS@Uw?F5(eln`lA5WMkAVwk z6(1gr5%VDf8>tN;vdaPZYs8yBSJ^oba~WDr`qr8Oh#ok4VLQ3lrJrZ_Xm(T@FM0qa z&kxcByGv0F-Fx%t@9vZ7JP$}yAKpn-r^LhBTLwsS1J)bs6T{~SIQ6H$7qanXOrs1*Z5c~M%>RPFWj8X;g2@Lhm?HnEOmg0If6exM<_Fa9>!5P zv6(xpC9c)Yz1{ue6}vOIV(QK_dbu(^ad>yOhx?(?cWg0n`J-318#Q=eVZOiuW}A1? z=YKkEE?wkr+3_PaFv)gRxm)xjwl4{Gcz$5;$RixdVH2Ds+=H?$xTUn`QZ<#!D zWRP4okEG?OLnjctlnTlg5)kz*Yn=}m<^joJPN)}L??y(J86Fk_PaZ`{q?IKql37h; zDKAk4_|={_s%_q*rZ}MznUn?=QC9T$A!MnV>~b~n=uXQdTx6` z)C4lw2Vd8?lJqhAV%eA%mg9eTcNjsG(q@@$etAi9{uE1m1hj1!jelwHV;%czJVoYcrZ=vANJHDiH$G) zek&XC9nl=^c*OxElr7lsK6+aN5c^^)p0n;58u$EC`TpvB9KEV=zK9QdPpmKCHANCK zliMaTnv1|oI8A%NctUtQg)_&D9wYY|Iwm&nkURyL3PVzKxQI{K6C{+zFGk`XQGDw} zv$z(!mCfUPd6h*?RowKmNy|p2Mri1laA2VU*^f5fL8Ne4IPc)ybITH=)f$-My53); zfsHD{N>w!&UkTyOxD>>Ey0g^%;L)A?P_Nyhcd+dwhH5DN?-^*`{IEk;(NK z+#s-OPFRbbX|Uo9=Y@)pgD@SCE!UCmYYVmF+$i4Kgz2lR3|L_DxX-u)DSS39jaf=r zT6deEL2ULQJHvU~(|2vtWZ zLueKkQ*#|Bj9fi4c9{)Y&z^&}>=~e5Y-HCkQ7Mw zXCH5+<@YAqb|zki@0M(%ccdpqTJ62ZPg~bZ9%dCF9k!S%_lroxG?x3NpXG4ZBn}!6 z+=_Y!1xqxCN~6zvXAyVg)}YKk4ib#`<>h_p{S$I>vi*LYB5ST+3mf_t)@{}Ih`};0 z29&^wWHWl>8kd64(wY}#hrVQAh&s7gbeHd|IZAStUZ&PSb3$B{PvD=+ zQkSe%LJ0K>h&Kj#S8^)h9GXvu0IZ=3Z>3DSi8{T;a z0b*muMkNGwF;o1RwtCZDg#97P8vE(~`hga&m%k(gTR6qI^gs7yTIO@ay}Te)Hx6Eg zd%2g}G&u)zqqNrD5nG*q8XFK&z9RjIS(Q6DYG^p!6>M30Ef+5|le|Ud>m9T2((_H@ zmT!+5i$HN{<G+1EEoc4AS9vm>QDZpO>K6M{G^b)txOnqNOvTfV zwR^y>(e?%b$$pu79ydu6M>3?3(>(2u(=dN7HK{92%u6nm^iDzS@)?5XBIF{B#CklVg~i#wA$0R9A~jYSgt2E^Wysxcp!2- zJy+&-mzNYaZTSq9cjqTE4)av2f-f$0H4?(;)nFcK>Cqg8V1?|=v!Y(*^*0|9I;_Rhhiwc^cQM&I zs2P#p?_{f-yhS#$Z%c?knJ_g7Zhv%L*{tf?J?E8j94bImWV|QMY5x(sTCL_62EdT)xWZ#KY;8qi zzh&-cv3YOkp`;b}=k-{kwTe#GjC6kh`OVE6++^#^n`2$=$t@u!WTiOfEEDax{k6!e z@X;4kniF^87>l=U_UXRvHKDfp>vDPBi03g%yHSkk525SM)oqOWGqYp4$RD*p_K`zZ zX5;Tx^`n&DE+;ujb3D5nIv6Mom3jfVZ5mIfq!jf|AhPk0p*BCT0x8R9-BE8{1h;FQswTy?v#0}-38B!kczy{x;$7!io^DZ=IcJY##vEYDk$eMl;r^~T9QM) zQtubaNKNtRwxEV=;ce#Z4d5>nKyB3}bT9N~-_eBgFflJtua+a>1#3WkFbOfK>wALd zZQJFC>tFY+A8cE=I=Kr&9)?klwAYSC8EBln7`QBc`8b2H&Uw!rU@nG`1p+M z_PaAlj^s@QS_#v-S7a>mvT=DTFWy=ZjjGOXi5cF@lwE;85aI6_m*ok~r?Q!5Pm%ZT?$+H*@!&OVYR1ei_3V-7Rug|y! z6$Mw3zfY~M&=eRqCgXBTaB?UI^f`~CMbB=}$Mp5L0V>1!a|Lt#a+4g!0f$6;UDKhZ zlL^j^u4Vmh%}jY4)Cwro5tJ1AQGq1f_B}RfX)D2nMS91)Y;HB$dH?2hjtC#Za)<9l z3Xk+rZ6knNtjm9pc2D}(wY6@|ZX5l(cbwO2oUZoqp~U011TV#IhMJfGfJ%N_y5pEr z$$IA>?#}aHx9?aiZ|z18x!q7sz$jnVblQi|AhW85+>7y6btIi|OvFBI?tT(4eXVCg zeP8}0!iu@r=PR>rJ3wq*!=CC<_ihZL5#EG)I$$%%kh7e$zQ1S@xv6Or7!_P&%MPMk zACVS&BE)NLV(qN8MOV5C`xbf8IbN#MmeEcdWYA$OwFX;!1z7PC6DoHe>+fVejhMzC z1S8qnm<(G9MXIvx3DE3&Qo+7^LNi#xb$$M2LL^jXh)cbb3h%G(i91(WK}lj~^MOAm zA?4cXvn!=%bKJ^P|1)ix8c1H28Z^2L({~B=9);^+7Yn7*L|+tIAJG4NPUMk$gC5&z zQeEbR@FbxHdE`+3^XSBSPAWGx5R7Z8yZbLJA~9Q9x(L@tqt{q61Em+ikqTux8^kZ8DQrK4FB3r5Qx$xHG!>D| zA6?vk{*>E?Mj18vgMk%hzN`ZwTFY1ltHNF5S%);i;&*l-ACcsI3pnD=iX?}s!s}HC z1As^77XFUGAm4O;CtDdaLT6%hOQ>4n&pujtYU7jL7onxKBM-_>lW}>$dS5% z{BRX)SUzjTUq2m{I3;m4ULG3n!EI@PR04_rJlShCF+6IG-&{VfY0G+|OLpY);~Tcs ze2Y)Mw|IXXzocJ3+sL=yh{1EwAusXV3dh~TOl+|FVY|@xU{j6Ef?(e4;reCW_43yL z<76IskRMUIl)Uop?JzOW;#+p#(crQzC^Ot~KFDqBhT`=!Rk%4%b1(y9h4j`weN&J! zbyYm>{7aU7#kdNy2Zqx-hUyr=|4NbL%;CXS<-w%jL)X z(3_2Lz*r;mD9!Y`&iV2=x+?sNv)b*Cwn}{YDuYzmi4vn!c+r}V?AzoFZAreI-4!3+ zY{Td}nm@04BAKyM->B1)oKRD#r|^W|jYVjcSAs1YI=xx>$jpFe*KbLKby=*pW)eFs z3ZSXO09)sD}&}V6ipbE(Y~?r$YTn{V-9};R(?Z6wH9Dqxnt8t&~=!h3e%FyMY4}MkN68X-2kX^|Im5y$c6sN{v&x4l_54O-p{PrDCP` zpOp-`$#WIx;mb_%^9f@!#b^Gv=)X8dl(G-ESKr#_UVal#eY9!`MLqLs4DUCH##vQR z*2n?o*KjGB*u!M&?xGOuHa@Hn5s811Ma6+Zz~-qI^cWAxkz$M9EYF+65Y<;MSmJ$H zrmYW$Ykr63;#?@3U~a9Yw$VB(W+T|LSC!M@RS~PJ#aBNlsh@MN)U_GZ+y4ALdVH-Z zeZ7rMl*xi!f6B*qX6Hr-YTWI3@7e|R;u4nUs>YIecpOF-fke*=0lHfETe!@N?>>DK zH=;xe|L}n!7YQPC**{jgAE6=E{~Z{`{~?;C(Z&12K1p^KRB#YWTRU?2RV!>AocDk%*gKH;(HiW`{1C zLgUncZHb`P0zyddG&COjHi2(%mgVv|gu%=`hPvnQickVe$8=lkQe4}&0*&it^=Vd~ zVz5rO$n;=raC-!!5NB|-XZOI{gu$ai!cKY`c7x4qn^>9w9*^aS`tLIdSOvMcwHy)z zisz9h?)wgaHN^ZNO1m|OBga`a*37=gS%}sQp9b3`#|ZInRQKnNUU+Pz_?9%$FWdS@ zDK<8SL9C$=vFNfCZZ*J(vU|VM+)OqeUmu(7t6G4CEYvRUzK*`Qc@f3dneu^f+iG!g zxv+3dL+uJwWvD@yd7%RLmAuTRViISB>GdFBTIdcF28A`w;mJ|!FUG!hkwvww>N>lf z{H={Dx0PPqaV^{;baO8&Z#4W&_23HA>#O7j4>~jvphax5{G4W932b+Oq40dauN4&f zHNyo<4ks5vV~{U|A^h&ku)Ss;0}g#CCAB3 zx!5?ck zw{=3Qkp*j2pk4kf)hQYui~#aNqul$soANTlEt(Bg?n5v;dVgpctq zgK8zA*my$SKTIf^aU6WAcAVx*VfEg7ZkR4Xkr@Rqgp~nl)WKhG;{9Wdad0u6&{I#2 zxKYvs;M&vr=pb8WY#((GbJMo#x zxUcc)yW;DGO<4}gi6di1&45IQZgY_)!A;*)F;lrKSVH5fXFw*)gR$$6cTNB0*>AV^ zw*?Qj?T1Fkol|$DCNdN;)9*Q?6o(#96gu%a7X>rtoCf7n-ECFW5M|6Fal%oQ_HyFT88UEWBj-cYRmoJO?h1i zO8Pb`owZMsyI;28tb{Eo<>GSuU*PNNxjvSV(T~f_NvO^Dd~+Bv4RFyUso1bz_tFj% zCD1oMN-R7Ol)jcmv3xpONAc4_)~6O6({Dh!!AVxU&q++=$T73FoVhi&?s_pYN1!5s zSLaZGTy$Mp1n=}=+x6NJ7#4%I%HoA<%SY4XdQFZO;2iFiQP0678T*1q9`dllr^)b=7CHG-dsj-%14Er*pm zRd^>8M#r;=H+aYIt_QD=wbxFhWWMQQ>)ENMK;y%e z-Iu6Jt^6|6l4x)u>Ylp;h!pn4O+sEjgtk(?U5Hp84IOs(ACPd#;dKgps1N!cG}yQ-Gvsh`Zg?5UQf#j}u^uV0^fBdXFH8Osx2Rn>nD?ts=VM5s(?3r8fR! zJ`WX_!j}fLK<(%2=>n7ezAMSisdM;Al^QJ_vPLj;mPAD$I~PIuyU==s!xUY zodiCv+RDXwU$axLZtbz}8BHq_1XqHo-^Kx4+f%NMl&->(9MD7SO zj&Z#}?1hK1F$*vE4Hl-52+kbud@c@%{KDPxs}pYe1D656Fec#qx9+xdyZ42hGFio=?^)UY_>^ z(>JtY69@hM-~dl%4gVj2NS%f*G|0Te8IlHlUZ{1k{U#Aat)_Xldr;o1s3ZVmargPD z;rI1QJ?8u0>5}@tQ>^!bMR8(pgdU-=nVzFZN}3-}d2iu(c}?B!g+r&S-sFg(f%#=% zzo*;ppCC$j0$qWo20Ac8Gv%A07eM$IXBHv$ov2<=J=H-@-^-4pGZ02IribPegl|FT^(ObV6vO4);?$6A_cuA+Vq1WmKIXgG`?%u zrna{Hm7|qSZ2EYj-pae%klBl5e4Y(Q1~p_8K*?L8**B54K6R1iQ(L|wGo#bCl5%MZ z{MaKF{!lpQcY)8@^9p+-R{^~zI?PY8%s*F`Jk24WY@RNKU0ezwO!ekJFkp|~0(i49 z_o5;d+*Sc(Jxsf-=YV#pfx^q|3d>HKjaXhv8upfShP@MxO3ECHoT?wPg+rAJ6j6d% zuauS&I`}i%EghL!ET5Xxwzd97;lDf-pr|@|G8SGFIUE-hbZa?YaLw!-y(k#t(PILzr}1;;g9@KM&6c28i1cn_xi z(F2R>(iI%Xx#oN~+xepmM0U{~Zb-ADBKO>klUgz|STaYC2~5Jw-3Rp*0M~QeAK_ zLT0jdy1u+74qNvm@lVU?i`<{VyiM-Y&YKwl`Xjzk0A)rN&XTzJ%RhJ_zfDfUp6RejT}_&K~L%hzXRUt_YZ--idup z{Yr*e6)6k#)Uosm3Dq!P+F%<1B=Fb-hzMKL%lx|uDvf&tWb2JnpRL}zSR>)WD&oy}+RNe&Hx|`=VR=Wi6 z7&fK)_A2^4+$>xJ4og%N88LV2S%ppZIE zH}jy~y(@yAt|h*1Nxup80`#-q*0us&eb+uNNliaG@F!bj(_qP@^T>u)(1yV%FpQ$n zoKE3aW`7m0ClO~zsXnJn<$2eljws67*~7k}IRJrorv^i1N>PKfyeLy1>m9%`U>1ap zV;J{k2lR8fH=dT%$B_tRpR2BUFNTgQel2SkW5@I})FPn?lSPtXkB>FA*)4J8-*uAW zCj}gqkZb2+L@sJuIUggVf$OL;Y>9EQh7-fNqMs=W2B_3h8cl_69%LDsEY$=;9~~S` zMh@TOiRbWVES8&JU#7~Z$xYEa`to)$0DF2z2*5Lsl*Ex<_be}5`*h@>p^QK!M@P+% z#{3!j79}}Lm5Fr$lPZBYi+=zlA@aChAd_LxVid4#ykJ+4hoZ1$en6D#@EK`u4o>V& zud!SQXGsUrKUS+``^EDi4qnc;`NSp8QTiL1dq1V|9XIXS zV;zJb0ww|#p08c?^r4SaJIza(jxgVH0p`+7SR4;gt3y0wS{a(dC@t93kb(EUJh7r& z7MBx@f$B+}QZfvbYQHp(Lu{6-@=K)G)# z;RhYWAL`WxFppsry{Tk|`?4(3?>~%ESH%KE zvcS^HtZR~v}xc}=m zvR>5rLTBTsUDrd2`cEyI1D3J_?_lI|P-a1-O+Q07RS0!rKToiU|Hn8yPY>0P*kiZc z6(Xfc;fiU?ES|Vm+ks*Vpm_tejb_d-eAbc^lTRL@sJAyiWcR9{&$P+wgPs~tFZ!}l z^6r|Pg5#quRe6tZSsl$ggp}?@@q&MP50oksD}Nwf6Z)+xqSVfwk?b#H5FhXn;mW?g zee;BWj^!4}gGSGiNNN?)^t(tIj;X|PR|DOk=*!w+gnJufT-E(`1wkOySh?PpR^$pf z=C&Fm7Jc|imd4*ZU&i=Zg0L;lkL9lVe!*P|<`G|EeP!OfoDbn!NH&?6Z=CV3jYg|# z?BpJ9lL>ALqBI(XWi4d6aqMAxVmN!5cj;efWj->$d#)NEJJ#<|R^9vcL-0&M-$#eJ zzrJyDNSoZz;=rD3V-miQ`OdMVdl2YHgHr|zD}9~CE)C84Tc1J1$`$3U&wl93G=jXD zZ9mA>7Sd(Tk3uUEial1UOn+{wlLde%u+wNNp8GgWG9I7a!G8;4$o z&2Ar8?dKiphR(Scds1)b80|OkURQWunL*dL1lfeu=EcspYtvf6+Di-L{;zd;19Afh z3TKDBiw*7_i^M3@x(AL@A~gpKShwgYD^G=;gxS8@9O=!cILWlyvqzha!M_d-1^uHa z0?SWjk&$Rw%}0NVm|eELTYj+3)|1iojv8};RmX+q5PG0x0z#`}9+*fyQ2{%ps7U;nnT3i34#>rSn2@(?>~%+MK$^b;eyk>j`K;Pxxt zUp)+`Wwxnw)l0~pdDmBNFbxO1%N1e|?`#a-wevf4WLUA6I)pOIM44FJ_75}Y7% za<*RY2Q7gH&(-O~t*m~}u&qGlDp4yW*3(ZHUi^}OdM%SXXPZjGZG(Utpil0LdTTRnCpSa}-t+SE`GR5a05{VN*n65{~ zi+7QCL&nSPW{W|;T=bXC(S}yeza@Zb%Y}M>bqdbK(|tE@kxUAbk*YcsUAYWuYwGL8 zXSK~8GsGO2jDT6{A~I|(i?tJVY;~Ikn%nJ5=u=PiI!-cViCVec8O4!_tVPC3-)Ziu z0Zoc+qud@e>ES`yL()+w8?FNF%<&fKS}whZL<|P!ZzL-mEZ?rOr|+*v^0EA!)!E~O_ba%&;*9IA zolizsa!TimzSm(GUWK++qz=+Ik&+@820c#?Ztm%XCE>V2FG1_;7W{V>WIW-d<~qN> z{)|8qXh!q-b2TG1AMYIt@65s?DEzUAV}}1r(M|F5F1#~WsH5)G2VY3OLi&0;my9QM zL);fdhGxx5^-4^Cd$-&mgc9N1BdV&j%1ih|7-dd@-0mFO&5E0iP^T<1nt~)(*5+P`KrfMS6pkxSQoNXO}tH@;S*V@zdXcUsE&Qh zkoX)6{0fsMPULHE!|ZD>_SPqK?8M}^w1UeW_$&2kT$zqS{*Dl$>2{rq^AAKf+$3I4 zslbVh%{kmT=4(zI?%M8hIVBDV0c+GUi)Gr*qmoMBmxR}%K_R8vtBq0#&Ln<8D%dwN zX>kpAbVWC%Ox9N${Hjz6(^5A2n+f1Ik0GeHcLj`&aX>$e34*En8Q{+qdkxN`e0P!Q zuT;iYl}dM4*Q0MgBHJ<84@Drs)lj-ad^2LCL9)}-LW5l0bPW}DSE?e=%7tHRP6c!f zCP99CfJmiG!~WA`Zs>WX_>h?A{&2eO`K0L$B~4a>l4;-RvWE$eh*xW9ls}c*r%2m& zhNbWPIhO^{^mI=usAMI#22L*o5en8{Hbu|a4~HQ9hIR0+{~&iYEP}?yfr8%s`I47J zMwZl{wRZeoXI={s$a<8gt4*Hsx&iJrQu%P^vb{~RDum$htr@A?>pqxhJV!|gGX zUL`*6%=J@W#QW;LfrYA4&d56JDBXjn3uVUsl49ZLp=uN_rZPtr;;F^iL`u&7(bYYE z=-J{N7h1bT#haD>N0mi%ys9&r^nC9XKh(_H-B%M1HioTc@Fodl-(@UPAvoeevvF5M z;u?_+EkqcJFxApR&f{>;#tk41X4PLBpc|{$-TFD}ZVekXDPVQ+63XB7XBQ-8=C;P3 z^%)ycbSmcLP%&N(tleOR42l01d>VaW(oyOFt;?XYt}bL$;8)^3M}APjS8m#_k+KnP z&zhAc!sRm}|8kYN?tC#ptdd*2*cMd_z!=a0ogK@^%YBXyrw*k^hJhtb)UY-Pp|U`b z;vm3-f2h$+A&q7+M}Mg-r9>2BEm^YPNmZ( z*7I4&!nFAzxpw5$n0?QdSE`^*s^a6@SRrre`i+>=SLtxw^z-@jraYqw@bSip;u!dK zTL9hZVjx|G5={P{9@`(L2W{{d>D%clZO4f70pf2!tc#MFU?)YLt-?Z9$-c2McL4VN z7W9D4WMOAN+6=I1Dfa)8xF9t6=O(>9LB!e%vOnrk?M0> zhwcO)UQOE|!|+=@H*wsyK!gv02uY?=#%_C5C4PYHuGzw%hucEDs@DbbO_Caz!aR{U z+)TI!k?P4(-i%WA5m2zQmZE^K6<+p?B|X5EGq$zw9(PfkANGFIjOw2MWg zKzz_(5iAbl)Py69NJEsQh^vxIDgheWS-`flG+rfqdEJahS*YUq)RCw7wJ6IA7i?_T zbD!-Qf(p&XhfA-KFoYvL#L~7U6T{tD%|dbL)o=N6;2}mx z!H~)1Fa$U)<8*lRd8*EEO<$_82C=Yv=lCg~$8GQ49)$Nx5fJEEaxF zl)u`I99^+<`OtY7_q=-`^1k9=uN<@9D*Adv0Q2^am|DSo=F?vA0J6!bIBOyEjpJ@H z2*UlQP-z!NN@6biXcIsE-B$>G4p#Bsxw4!W^oDs9n-adqf1greR# zfARMgj5m9@`A}9Oc~h#WMos)V%?-=nk`+S6=Q3Tqj&FJVY_lXU-j8{UUQwRer*vNi zfYU!5rO0Ef|MdN1vc@5-WmGYcr9CI@`kiQ7RL+ztb22U{WAeB5;o6w`-4GP9`W`>S z&_}b=Tjc-o#5;+YZe(ff8d~EuaP3uP~tc86jg5qVOZ*{cwJGU z(V#giqR;*#}M7(H=WegGj8QE45StkwQ)t zkDqA#;#%akszszb-d6hC&Y>(@IusF!_+GjwxIeDH(7}w)oA7)sg+;iwNG45>Jl=*4 znht+k)I22GMQiXwNWP<7d0VRrHC&g~daE&5*a?1)=?cFU!1v)>Lhov{i~V|%SV+9X z7((>eXMfQ-lj3T*{T)ezIo7*te0-jq5m672Z%@7nd89JjVZ=_Bbo1hLe3vR5GZ8VQK$3BS3rpv(TI z*if``DGY`pJFPa|qyC_%M6lc!v`aS!?Bf{jCRy3h2>YLHBX-_Z0cNP-YKG+9aVn&&bOWM*j$kk8_d6 z?(xLgln?2|OMK3fRpgLJC=#$Sl$ZdT<~F@JI^%N{SsMK=7C#~w8JCp|ODKUjfulX0 zRNnimv2(P`!_|JMWw#2*v%0*WmV!FHXJnXm$FI8bV27U>i%M0TS`CxQy!TVI4+Hku zCU|>U(96OE+nSptiO19IE`KjZoFmE96%r=Y#&G77AMX8@(Ad$co7FH1**~KH7%QV@ zq2D^0XG`W%Kwy))B%jtc34_bP*&~!hvXkx2x61x?cm8VL+eR&j+qieTj zPcf!P__24db-NUOd7qw4jxNS~Rn}k`w;L-!+JMkh*E38;hxBHxU%E}SZ(^oQnTt9( z6U*##{JUsmtt^A>6&UNN5mxBooYco1=6i8#6YtoyZl1O{hP>>^Lrts-xuXYNTZ$>u zpfaVW+VhuTa-W6(Y5#`hX)X;5E-i}{XxWY&i-0|tDN1{4YkvF|i+8ibuT!lOje;w< zkwW?d17jC~Qo*}a1btjLC$U87&ALRfBUk{XiT&dcIexY(=W<~(r-<*5(6%;&Rm^bw z25DIcIe0Kk;h0MuZVN`^O#>~4>J*7fwa5457~M`DW}CLMhrohubV?aHB0*q%i?F@) zYwum|^K0)Lu4E}LYfhYog~=@Pv>I86X>U>2n?#DFw@m4G^1i2s(0@%DkwFgxASub&ET6!HG@u+jB+p_yO(GoOV3#Nw9K0GZvg&5PWug{2eB{b>*22oK9 zncm+N91?M1gpr#Brp6}vt7WNs#8Bn}aw1X4oh$4)t6v( zbHB1*nkJIRlGpzHfGgQqz$g + + + + +OpenSPP PII Encryption + + + +
+

OpenSPP PII Encryption

+ + +

Alpha License: LGPL-3 OpenSPP/OpenSPP2

+

Field-level encryption for PII data using AES-256-GCM with searchable +blind indexes. Provides transparent encryption/decryption through a +mixin, UI-based configuration of which fields to encrypt, and audit +logging of PII field access.

+
+

Key Capabilities

+
    +
  • Encrypt char/text fields transparently using AES-256-GCM authenticated +encryption
  • +
  • Search encrypted data via blind indexes without decryption (exact, +partial, or phonetic matching)
  • +
  • Configure field encryption through UI instead of code changes
  • +
  • Audit all PII field access (reveal, export, decrypt, modify, delete) +with IP and user agent tracking
  • +
+
+
+

Key Models

+ ++++ + + + + + + + + + + + + + + + + +
ModelDescription
spp.encrypted.field.mixinAbstract mixin for models with +encrypted PII fields
spp.field.encryption.configUI-based configuration for +enabling encryption on specific +fields
spp.pii.audit.logAudit log of PII field access +events
+
+
+

Configuration

+

After installing:

+
    +
  1. Navigate to Settings > Key Management > PII Encryption > Field +Configuration
  2. +
  3. Create a new configuration selecting the model and field to encrypt
  4. +
  5. Choose the blind index type: Exact (full normalized match), Partial +(last 4 characters), or Phonetic (Soundex for names)
  6. +
  7. Enable encryption and blind index options
  8. +
+

Bulk migration of existing plaintext data (scan, dry-run, backup, +rollback) is provided separately and depends on the data classification +module.

+
+
+

UI Location

+
    +
  • Configuration: Settings > Key Management > PII Encryption > Field +Configuration
  • +
  • Audit Log: Settings > Key Management > PII Encryption > Audit Log
  • +
+
+
+

Security

+ ++++ + + + + + + + + + + + + + + + + +
GroupAccess
spp_pii_encryption.group_encryption_adminFull CRUD on field +configuration; Read on audit log
base.group_systemRead/Create on audit logs
base.group_userRead field encryption +configuration
+
+
+

Extension Points

+
    +
  • Inherit from spp.encrypted.field.mixin on any model with PII +fields
  • +
  • Implement _get_encrypted_fields() to specify which fields to +encrypt (or configure via UI)
  • +
  • Override _get_encryption_key(field_name) to customize key +retrieval per field
  • +
  • Override _normalize_for_index(value, index_type) to customize +blind index normalization
  • +
  • Use search_by_blind_index(field_name, search_value) to search +encrypted fields
  • +
  • Call log_field_access(model, record_id, field, action, reason) to +audit PII access
  • +
+
+
+

Dependencies

+

base, spp_key_management, spp_registry, spp_security

+
+

Important

+

This is an alpha version, the data model and design can change at any time without warning. +Only for development or testing purpose, do not use in production.

+
+

Table of contents

+ +
+

Bug Tracker

+

Bugs are tracked on GitHub 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.

+

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

+
+
+

Credits

+
+

Authors

+
    +
  • OpenSPP.org
  • +
+
+
+

Maintainers

+

Current maintainer:

+

gonzalesedwin1123

+

This module is part of the OpenSPP/OpenSPP2 project on GitHub.

+

You are welcome to contribute.

+
+
+
+
+ + diff --git a/spp_pii_encryption/static/src/js/masked_field.js b/spp_pii_encryption/static/src/js/masked_field.js new file mode 100644 index 000000000..df772c8db --- /dev/null +++ b/spp_pii_encryption/static/src/js/masked_field.js @@ -0,0 +1,173 @@ +/** @odoo-module **/ + +import {registry} from "@web/core/registry"; +import {CharField} from "@web/views/fields/char/char_field"; +import {Component, useState} from "@odoo/owl"; +import {useService} from "@web/core/utils/hooks"; + +/** + * MaskedCharField - A field widget that displays masked PII values + * with the ability to reveal the actual value for authorized users. + * + * Usage in XML: + * + * + * Options: + * - mask_pattern: Custom mask pattern (default: "****") + * - reveal_group: Security group required to reveal (default: any authenticated user) + * - audit_reveal: Log reveal actions (default: true) + */ +export class MaskedCharField extends CharField { + static template = "spp_pii_encryption.MaskedCharField"; + static props = { + ...CharField.props, + maskPattern: {type: String, optional: true}, + revealGroup: {type: String, optional: true}, + auditReveal: {type: Boolean, optional: true}, + }; + + setup() { + super.setup(); + this.state = useState({ + isRevealed: false, + isLoading: false, + }); + this.rpc = useService("rpc"); + this.notification = useService("notification"); + this.user = useService("user"); + } + + get maskedValue() { + const value = this.props.record.data[this.props.name]; + if (!value) return ""; + + const pattern = this.props.maskPattern || this.getMaskPattern(); + return this.applyMask(value, pattern); + } + + get displayValue() { + if (this.state.isRevealed) { + return this.props.record.data[this.props.name] || ""; + } + return this.maskedValue; + } + + getMaskPattern() { + // Get mask pattern from field definition or use default + const field = this.props.record.fields[this.props.name]; + if (field && field.mask_pattern) { + return field.mask_pattern; + } + return "****-****-####"; + } + + applyMask(value, pattern) { + if (!value || !pattern) return "****"; + + // Pattern interpretation: + // * = mask this character + // # = show this character from the end + // Other chars = literal + + const valueChars = value.split(""); + const patternChars = pattern.split(""); + let result = ""; + let valueIdx = 0; + const endIdx = value.length - 1; + + // Count # in pattern to know how many chars to show from end + const hashCount = patternChars.filter((c) => c === "#").length; + let showFromEnd = hashCount; + + for (const patternChar of patternChars) { + if (patternChar === "*") { + // Mask character + result += "•"; + valueIdx++; + } else if (patternChar === "#") { + // Show character from end + const showIdx = value.length - showFromEnd; + if (showIdx >= 0 && showIdx < value.length) { + result += value[showIdx]; + } + showFromEnd--; + } else { + // Literal character (like - or space) + result += patternChar; + } + } + + return result || "••••••••"; + } + + async toggleReveal() { + if (this.state.isRevealed) { + // Hide the value + this.state.isRevealed = false; + return; + } + + // Check if user can reveal + const canReveal = await this.checkRevealPermission(); + if (!canReveal) { + this.notification.add("You don't have permission to view this data.", { + type: "warning", + }); + return; + } + + this.state.isLoading = true; + + try { + // Audit the reveal action if enabled + if (this.props.auditReveal !== false) { + await this.auditRevealAction(); + } + + this.state.isRevealed = true; + } catch (error) { + this.notification.add("Failed to reveal value.", { + type: "danger", + }); + } finally { + this.state.isLoading = false; + } + } + + async checkRevealPermission() { + const revealGroup = this.props.revealGroup; + if (!revealGroup) { + // Default: any authenticated user can reveal + return true; + } + + // Check if user has the required group + return await this.user.hasGroup(revealGroup); + } + + async auditRevealAction() { + const recordId = this.props.record.resId; + const modelName = this.props.record.resModel; + const fieldName = this.props.name; + + await this.rpc("/web/dataset/call_kw", { + model: "spp.pii.audit.log", + method: "log_field_access", + args: [modelName, recordId, fieldName, "reveal"], + kwargs: {}, + }); + } +} + +MaskedCharField.template = "spp_pii_encryption.MaskedCharField"; + +// Register the widget +registry.category("fields").add("masked_char", { + component: MaskedCharField, + supportedTypes: ["char", "text"], + extractProps: ({attrs}) => ({ + maskPattern: attrs.mask_pattern, + revealGroup: attrs.reveal_group, + auditReveal: attrs.audit_reveal !== "false", + }), +}); diff --git a/spp_pii_encryption/static/src/scss/masked_field.scss b/spp_pii_encryption/static/src/scss/masked_field.scss new file mode 100644 index 000000000..b0ce628b3 --- /dev/null +++ b/spp_pii_encryption/static/src/scss/masked_field.scss @@ -0,0 +1,36 @@ +.o_masked_field { + .o_masked_value { + font-family: monospace; + letter-spacing: 0.1em; + } + + .o_masked_toggle { + opacity: 0.6; + transition: opacity 0.2s ease; + + &:hover { + opacity: 1; + } + + &:focus { + box-shadow: none; + } + } + + // Visual indicator for masked state + &.is-masked .o_masked_value { + color: var(--bs-secondary); + } + + // Ensure proper alignment in form views + .o_field_widget & { + min-height: 1.5rem; + } +} + +// List view styling +.o_list_view .o_masked_field { + .o_masked_toggle { + font-size: 0.85em; + } +} diff --git a/spp_pii_encryption/static/src/xml/masked_field.xml b/spp_pii_encryption/static/src/xml/masked_field.xml new file mode 100644 index 000000000..367e76169 --- /dev/null +++ b/spp_pii_encryption/static/src/xml/masked_field.xml @@ -0,0 +1,40 @@ + + + + +
+ + + + + + + + + + + +
+
+ +
diff --git a/spp_pii_encryption/tests/__init__.py b/spp_pii_encryption/tests/__init__.py new file mode 100644 index 000000000..76d22088f --- /dev/null +++ b/spp_pii_encryption/tests/__init__.py @@ -0,0 +1,3 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. +# Key provider tests are now in spp_key_management +from . import test_encrypted_field_mixin diff --git a/spp_pii_encryption/tests/test_encrypted_field_mixin.py b/spp_pii_encryption/tests/test_encrypted_field_mixin.py new file mode 100644 index 000000000..54a8bbe4b --- /dev/null +++ b/spp_pii_encryption/tests/test_encrypted_field_mixin.py @@ -0,0 +1,128 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. +"""Tests for the encrypted field mixin with spp_key_management integration.""" + +import base64 + +from odoo.tests.common import TransactionCase +from odoo.tools import config + + +class TestEncryptedFieldMixin(TransactionCase): + """Tests for spp.encrypted.field.mixin.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + + # Configure master key for key management + # Set in odoo config (not ir.config_parameter) as required by key provider + cls._original_master_key = config.get("spp_master_key") + test_master_key = base64.b64encode(b"M" * 32).decode() + config["spp_master_key"] = test_master_key + + # Set up default key provider + existing_default = cls.env["spp.key.provider.registry"].search([("is_default", "=", True)]) + if not existing_default: + cls.env["spp.key.provider.registry"].create( + { + "name": "Test Default Provider", + "provider_type": "database", + "is_default": True, + } + ) + + cls.Mixin = cls.env["spp.encrypted.field.mixin"] + + @classmethod + def tearDownClass(cls): + # Restore original master key configuration + if cls._original_master_key: + config["spp_master_key"] = cls._original_master_key + elif "spp_master_key" in config.options: + del config.options["spp_master_key"] + super().tearDownClass() + + def test_encrypt_decrypt_roundtrip(self): + """Test that encryption and decryption are reversible.""" + mixin = self.Mixin + + plaintext = "123-456-789" + field_name = "test_field" + + # Encrypt + encrypted = mixin._encrypt_value(plaintext, field_name) + self.assertIsNotNone(encrypted) + self.assertNotEqual(encrypted, plaintext) + + # Decrypt + decrypted = mixin._decrypt_value(encrypted, field_name) + self.assertEqual(decrypted, plaintext) + + def test_encrypt_empty_value(self): + """Test that empty values are handled correctly.""" + mixin = self.Mixin + + result = mixin._encrypt_value(None, "test_field") + self.assertIsNone(result) + + result = mixin._encrypt_value("", "test_field") + self.assertEqual(result, "") + + def test_blind_index_consistency(self): + """Test that blind indexes are deterministic.""" + mixin = self.Mixin + + value = "123-456-789" + field_name = "national_id" + + index1 = mixin._compute_blind_index(value, field_name) + index2 = mixin._compute_blind_index(value, field_name) + + self.assertEqual(index1, index2) + + def test_blind_index_normalization(self): + """Test that normalized values produce same index.""" + mixin = self.Mixin + + # These should produce the same index (exact matching) + index1 = mixin._compute_blind_index("123-456-789", "test", "exact") + index2 = mixin._compute_blind_index("123 456 789", "test", "exact") + index3 = mixin._compute_blind_index("123.456.789", "test", "exact") + + self.assertEqual(index1, index2) + self.assertEqual(index2, index3) + + def test_partial_index(self): + """Test partial index (last N chars).""" + mixin = self.Mixin + + normalized = mixin._normalize_for_index("123-456-7890", "partial") + self.assertEqual(normalized, "7890") + + def test_soundex(self): + """Test Soundex phonetic encoding.""" + mixin = self.Mixin + + # Same pronunciation should have same Soundex + self.assertEqual(mixin._soundex("Robert"), mixin._soundex("Rupert")) + self.assertEqual(mixin._soundex("Smith"), mixin._soundex("Smythe")) + + # Different names should have different Soundex (usually) + self.assertNotEqual(mixin._soundex("Robert"), mixin._soundex("Michael")) + + def test_different_fields_different_encryption(self): + """Test that same value encrypted for different fields is different.""" + mixin = self.Mixin + + plaintext = "same-value" + + encrypted1 = mixin._encrypt_value(plaintext, "field_a") + encrypted2 = mixin._encrypt_value(plaintext, "field_b") + + # Different AAD means different ciphertext + # (Actually the nonce makes them different anyway) + self.assertNotEqual(encrypted1, encrypted2) + + # But both should decrypt to same value + self.assertEqual(mixin._decrypt_value(encrypted1, "field_a"), plaintext) + self.assertEqual(mixin._decrypt_value(encrypted2, "field_b"), plaintext) diff --git a/spp_pii_encryption/views/audit_log_views.xml b/spp_pii_encryption/views/audit_log_views.xml new file mode 100644 index 000000000..c4297b2ec --- /dev/null +++ b/spp_pii_encryption/views/audit_log_views.xml @@ -0,0 +1,142 @@ + + + + + spp.pii.audit.log.view.tree + spp.pii.audit.log + + + + + + + + + + + + + + + + spp.pii.audit.log.view.form + spp.pii.audit.log + +
+ + + + + + + + + + + + + + + + + + + + + +
+
+
+ + + + spp.pii.audit.log.view.search + spp.pii.audit.log + + + + + + + + + + + + + + + + + + + + + + + + + + + + PII Audit Log + spp.pii.audit.log + list,form + + {'search_default_last_30_days': 1} + +

+ No PII access logs yet +

+

+ This log tracks all access to sensitive PII fields including + reveal, export, and modification actions. +

+
+
+
diff --git a/spp_pii_encryption/views/field_encryption_config_views.xml b/spp_pii_encryption/views/field_encryption_config_views.xml new file mode 100644 index 000000000..abf260f2b --- /dev/null +++ b/spp_pii_encryption/views/field_encryption_config_views.xml @@ -0,0 +1,198 @@ + + + + + spp.field.encryption.config.list + spp.field.encryption.config + + + + + + + + + + + + + + + + spp.field.encryption.config.form + spp.field.encryption.config + +
+ +
+ + +
+ + + + + + + + + + + + + + + + + + + + + +
+
+
+
+ + + + spp.field.encryption.config.search + spp.field.encryption.config + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Field Encryption Configuration + spp.field.encryption.config + list,form + + {'search_default_filter_encrypted': 1} + +

+ Configure field-level encryption +

+

+ Select which fields should be encrypted with AES-256-GCM. + Enable blind indexes to allow searching encrypted values without decryption. +

+

+ Index Types: +

    +
  • Exact Match: Full value matching (normalized)
  • +
  • Partial (Last 4): Match last 4 characters only
  • +
  • Phonetic (Soundex): Sound-alike matching for names
  • +
+

+
+
+
diff --git a/spp_pii_encryption/views/menu.xml b/spp_pii_encryption/views/menu.xml new file mode 100644 index 000000000..c75e4a2a0 --- /dev/null +++ b/spp_pii_encryption/views/menu.xml @@ -0,0 +1,29 @@ + + + + + + + + + + + From e1f509a9fc226a5c03b324e5dbbd6fa2bb2669f8 Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Wed, 10 Jun 2026 15:58:12 +0800 Subject: [PATCH 02/14] fix(spp_pii_encryption): resolve CI lint failures - masked_field.js: remove unused vars (Component import, valueChars, endIdx, valueIdx, unused catch binding) flagged by eslint no-unused-vars - regenerate README.rst / index.html to match the manifest maintainers (oca-gen-addon-readme was failing on stale maintainer block) --- spp_pii_encryption/README.rst | 7 +++++-- spp_pii_encryption/static/description/index.html | 4 ++-- spp_pii_encryption/static/src/js/masked_field.js | 8 ++------ 3 files changed, 9 insertions(+), 10 deletions(-) diff --git a/spp_pii_encryption/README.rst b/spp_pii_encryption/README.rst index 6f1645150..5d4eb6647 100644 --- a/spp_pii_encryption/README.rst +++ b/spp_pii_encryption/README.rst @@ -144,13 +144,16 @@ Authors Maintainers ----------- +.. |maintainer-jeremi| image:: https://github.com/jeremi.png?size=40px + :target: https://github.com/jeremi + :alt: jeremi .. |maintainer-gonzalesedwin1123| image:: https://github.com/gonzalesedwin1123.png?size=40px :target: https://github.com/gonzalesedwin1123 :alt: gonzalesedwin1123 -Current maintainer: +Current maintainers: -|maintainer-gonzalesedwin1123| +|maintainer-jeremi| |maintainer-gonzalesedwin1123| This module is part of the `OpenSPP/OpenSPP2 `_ project on GitHub. diff --git a/spp_pii_encryption/static/description/index.html b/spp_pii_encryption/static/description/index.html index 54d0ce121..7b2739805 100644 --- a/spp_pii_encryption/static/description/index.html +++ b/spp_pii_encryption/static/description/index.html @@ -519,8 +519,8 @@

Authors

Maintainers

-

Current maintainer:

-

gonzalesedwin1123

+

Current maintainers:

+

jeremi gonzalesedwin1123

This module is part of the OpenSPP/OpenSPP2 project on GitHub.

You are welcome to contribute.

diff --git a/spp_pii_encryption/static/src/js/masked_field.js b/spp_pii_encryption/static/src/js/masked_field.js index df772c8db..cdf2317fb 100644 --- a/spp_pii_encryption/static/src/js/masked_field.js +++ b/spp_pii_encryption/static/src/js/masked_field.js @@ -2,7 +2,7 @@ import {registry} from "@web/core/registry"; import {CharField} from "@web/views/fields/char/char_field"; -import {Component, useState} from "@odoo/owl"; +import {useState} from "@odoo/owl"; import {useService} from "@web/core/utils/hooks"; /** @@ -69,11 +69,8 @@ export class MaskedCharField extends CharField { // # = show this character from the end // Other chars = literal - const valueChars = value.split(""); const patternChars = pattern.split(""); let result = ""; - let valueIdx = 0; - const endIdx = value.length - 1; // Count # in pattern to know how many chars to show from end const hashCount = patternChars.filter((c) => c === "#").length; @@ -83,7 +80,6 @@ export class MaskedCharField extends CharField { if (patternChar === "*") { // Mask character result += "•"; - valueIdx++; } else if (patternChar === "#") { // Show character from end const showIdx = value.length - showFromEnd; @@ -125,7 +121,7 @@ export class MaskedCharField extends CharField { } this.state.isRevealed = true; - } catch (error) { + } catch { this.notification.add("Failed to reveal value.", { type: "danger", }); From cc9c2467dd44065d582163f0e47090417576a708 Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Wed, 10 Jun 2026 16:51:35 +0800 Subject: [PATCH 03/14] test(spp_pii_encryption): cover config model, audit log, and mixin helpers Raise patch coverage on the migrated module: - test_field_encryption_config: get_encrypted_fields/get_field_config/ is_field_encrypted/get_index_type, index-field-name computation (index vs last4), toggle actions, and the char/text-only constraint - test_audit_log: log_field_access, get_access_history, get_user_access_history, display_name - test_encrypted_field_mixin: _get_encrypted_fields default, _get_index_type default, phonetic/passthrough normalization, graceful decrypt failure, and search helpers when no index column exists Note: the mixin's create/write/read ORM hooks can't be unit-tested in isolation (a tests/-defined concrete model isn't registered in this harness, same limitation spp_approval skips around). They are exercised by consumer modules that apply the mixin to real PII fields. --- spp_pii_encryption/tests/__init__.py | 2 + spp_pii_encryption/tests/test_audit_log.py | 43 +++++++++ .../tests/test_encrypted_field_mixin.py | 31 ++++++- .../tests/test_field_encryption_config.py | 88 +++++++++++++++++++ 4 files changed, 163 insertions(+), 1 deletion(-) create mode 100644 spp_pii_encryption/tests/test_audit_log.py create mode 100644 spp_pii_encryption/tests/test_field_encryption_config.py diff --git a/spp_pii_encryption/tests/__init__.py b/spp_pii_encryption/tests/__init__.py index 76d22088f..1ccefd13b 100644 --- a/spp_pii_encryption/tests/__init__.py +++ b/spp_pii_encryption/tests/__init__.py @@ -1,3 +1,5 @@ # Part of OpenSPP. See LICENSE file for full copyright and licensing details. # Key provider tests are now in spp_key_management from . import test_encrypted_field_mixin +from . import test_field_encryption_config +from . import test_audit_log diff --git a/spp_pii_encryption/tests/test_audit_log.py b/spp_pii_encryption/tests/test_audit_log.py new file mode 100644 index 000000000..f538bec8d --- /dev/null +++ b/spp_pii_encryption/tests/test_audit_log.py @@ -0,0 +1,43 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. +"""Tests for spp.pii.audit.log (PII access audit trail).""" + +from odoo.tests.common import TransactionCase + + +class TestPIIAuditLog(TransactionCase): + """log_field_access and history query helpers.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.Audit = cls.env["spp.pii.audit.log"] + + def test_log_field_access(self): + log = self.Audit.log_field_access("res.partner", 1, "name", "reveal", reason="support call") + + self.assertTrue(log) + self.assertEqual(log.model_name, "res.partner") + self.assertEqual(log.record_id, 1) + self.assertEqual(log.field_name, "name") + self.assertEqual(log.action, "reveal") + self.assertEqual(log.reason, "support call") + self.assertEqual(log.user_id, self.env.user) + # display_name is computed from action/model/field. + self.assertEqual(log.display_name, "reveal res.partner.name") + + def test_get_access_history(self): + self.Audit.log_field_access("res.partner", 7, "email", "reveal") + self.Audit.log_field_access("res.partner", 7, "email", "export") + # An entry for a different record should be excluded. + self.Audit.log_field_access("res.partner", 8, "email", "reveal") + + history = self.Audit.get_access_history("res.partner", 7) + self.assertEqual(len(history), 2) + self.assertTrue(all(h.record_id == 7 for h in history)) + + def test_get_user_access_history(self): + self.Audit.log_field_access("res.partner", 9, "phone", "reveal") + + history = self.Audit.get_user_access_history(self.env.user.id) + self.assertTrue(history) + self.assertTrue(all(h.user_id == self.env.user for h in history)) diff --git a/spp_pii_encryption/tests/test_encrypted_field_mixin.py b/spp_pii_encryption/tests/test_encrypted_field_mixin.py index 54a8bbe4b..e10ed1804 100644 --- a/spp_pii_encryption/tests/test_encrypted_field_mixin.py +++ b/spp_pii_encryption/tests/test_encrypted_field_mixin.py @@ -4,7 +4,7 @@ import base64 from odoo.tests.common import TransactionCase -from odoo.tools import config +from odoo.tools import config, mute_logger class TestEncryptedFieldMixin(TransactionCase): @@ -126,3 +126,32 @@ def test_different_fields_different_encryption(self): # But both should decrypt to same value self.assertEqual(mixin._decrypt_value(encrypted1, "field_a"), plaintext) self.assertEqual(mixin._decrypt_value(encrypted2, "field_b"), plaintext) + + def test_get_encrypted_fields_default_empty(self): + """With no configuration, the mixin reports no encrypted fields.""" + self.assertEqual(self.Mixin._get_encrypted_fields(), []) + + def test_get_index_type_defaults_to_exact(self): + """An unconfigured field defaults to the 'exact' index type.""" + self.assertEqual(self.Mixin._get_index_type("national_id"), "exact") + + def test_normalize_phonetic_and_passthrough(self): + """Phonetic normalization uses Soundex; unknown index types pass through.""" + mixin = self.Mixin + self.assertEqual( + mixin._normalize_for_index("Smith", "phonetic"), + mixin._soundex("Smith"), + ) + # An unrecognized index type returns the stripped value unchanged. + self.assertEqual(mixin._normalize_for_index(" abc ", "unknown"), "abc") + + def test_decrypt_invalid_returns_none(self): + """Decrypting non-decryptable data fails gracefully and returns None.""" + with mute_logger("odoo.addons.spp_pii_encryption.models.encrypted_field_mixin"): + self.assertIsNone(self.Mixin._decrypt_value("not-valid-ciphertext", "national_id")) + + def test_search_helpers_without_index_field(self): + """Search helpers return an empty recordset when the index column is absent.""" + with mute_logger("odoo.addons.spp_pii_encryption.models.encrypted_field_mixin"): + self.assertFalse(self.Mixin.search_by_blind_index("national_id", "123")) + self.assertFalse(self.Mixin.search_by_partial("national_id", "0123")) diff --git a/spp_pii_encryption/tests/test_field_encryption_config.py b/spp_pii_encryption/tests/test_field_encryption_config.py new file mode 100644 index 000000000..61993997e --- /dev/null +++ b/spp_pii_encryption/tests/test_field_encryption_config.py @@ -0,0 +1,88 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. +"""Tests for spp.field.encryption.config (UI-based encryption configuration).""" + +from odoo.exceptions import ValidationError +from odoo.tests.common import TransactionCase + + +class TestFieldEncryptionConfig(TransactionCase): + """Public helpers and constraints of the field-encryption config model.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.Config = cls.env["spp.field.encryption.config"] + cls.partner_model = cls.env["ir.model"].search([("model", "=", "res.partner")], limit=1) + cls.name_field = cls.env["ir.model.fields"].search( + [("model_id", "=", cls.partner_model.id), ("name", "=", "name")], + limit=1, + ) + # A non char/text field to exercise the type constraint. + cls.bool_field = cls.env["ir.model.fields"].search( + [("model_id", "=", cls.partner_model.id), ("ttype", "=", "boolean")], + limit=1, + ) + + def test_create_computes_and_helpers(self): + cfg = self.Config.create( + { + "model_id": self.partner_model.id, + "field_id": self.name_field.id, + "encryption_enabled": True, + "blind_index_enabled": True, + "index_type": "exact", + } + ) + self.assertEqual(cfg.model_name, "res.partner") + self.assertEqual(cfg.field_name, "name") + self.assertEqual(cfg.display_name, "res.partner.name") + self.assertEqual(cfg.index_field_name, "name_index") + + self.assertIn("name", self.Config.get_encrypted_fields("res.partner")) + self.assertTrue(self.Config.is_field_encrypted("res.partner", "name")) + self.assertEqual(self.Config.get_index_type("res.partner", "name"), "exact") + self.assertEqual(self.Config.get_field_config("res.partner", "name"), cfg) + + def test_index_field_name_partial(self): + cfg = self.Config.create( + { + "model_id": self.partner_model.id, + "field_id": self.name_field.id, + "index_type": "partial", + } + ) + self.assertEqual(cfg.index_field_name, "name_last4") + + def test_disabled_blind_index_has_no_index_type(self): + self.Config.create( + { + "model_id": self.partner_model.id, + "field_id": self.name_field.id, + "blind_index_enabled": False, + } + ) + self.assertIsNone(self.Config.get_index_type("res.partner", "name")) + + def test_toggle_actions(self): + cfg = self.Config.create( + { + "model_id": self.partner_model.id, + "field_id": self.name_field.id, + } + ) + before_enc = cfg.encryption_enabled + cfg.action_toggle_encryption() + self.assertNotEqual(cfg.encryption_enabled, before_enc) + + before_bi = cfg.blind_index_enabled + cfg.action_toggle_blind_index() + self.assertNotEqual(cfg.blind_index_enabled, before_bi) + + def test_non_text_field_rejected(self): + with self.assertRaises(ValidationError): + self.Config.create( + { + "model_id": self.partner_model.id, + "field_id": self.bool_field.id, + } + ) From e0eb6898fcef6876b3b68b4cc777ff0930a42db6 Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Tue, 25 Aug 2026 10:12:35 +0800 Subject: [PATCH 04/14] fix(spp_pii_encryption): activate DB config lookup and clear stale blind indexes Two mixin fixes found in review + rigid testing: - env.get() returns an empty (falsy) recordset, so 'if FieldConfig:' never passed and the spp.field.encryption.config table was never consulted: the UI-based configuration was completely inert (bug also present in the openspp-modules source module). Compare against None instead. - Clearing an encrypted field left its *_index/*_last4 blind indexes in place, so the stale HMAC hashes kept matching searches after the PII was removed. create()/write() now share _apply_encryption_to_vals(), which clears the indexes when the value is cleared. Adds helper unit tests and a config-driven write() integration test. --- .../models/encrypted_field_mixin.py | 84 ++++++++-------- .../tests/test_encrypted_field_mixin.py | 97 +++++++++++++++++++ 2 files changed, 140 insertions(+), 41 deletions(-) diff --git a/spp_pii_encryption/models/encrypted_field_mixin.py b/spp_pii_encryption/models/encrypted_field_mixin.py index b48474416..f4baf81d1 100644 --- a/spp_pii_encryption/models/encrypted_field_mixin.py +++ b/spp_pii_encryption/models/encrypted_field_mixin.py @@ -77,9 +77,11 @@ def _get_encrypted_fields(self): Returns: list: Field names to encrypt """ - # Check database configuration + # Check database configuration. + # env.get() returns an (always falsy) empty recordset when the model + # exists, so the presence test must compare against None. FieldConfig = self.env.get("spp.field.encryption.config") - if FieldConfig: + if FieldConfig is not None: db_fields = FieldConfig.get_encrypted_fields(self._name) if db_fields: return db_fields @@ -130,7 +132,7 @@ def _get_index_type(self, field_name): str: Index type ('exact', 'partial', 'phonetic') or 'exact' as default """ FieldConfig = self.env.get("spp.field.encryption.config") - if FieldConfig: + if FieldConfig is not None: config_type = FieldConfig.get_index_type(self._name, field_name) if config_type: return config_type @@ -315,46 +317,26 @@ def _soundex(self, name): return (soundex + "0000")[:4] - @api.model_create_multi - def create(self, vals_list): - """Encrypt fields before create.""" - encrypted_fields = self._get_encrypted_fields() - if not encrypted_fields: - return super().create(vals_list) - - for vals in vals_list: - for field_name in encrypted_fields: - if field_name in vals and vals[field_name]: - plaintext = vals[field_name] - - # Encrypt - vals[field_name] = self._encrypt_value(plaintext, field_name) - - # Get configured index type - index_type = self._get_index_type(field_name) + def _apply_encryption_to_vals(self, vals, encrypted_fields): + """Encrypt configured fields in a vals dict and maintain blind indexes. - # Compute blind indexes if fields exist - index_field = f"{field_name}_index" - if index_field in self._fields: - vals[index_field] = self._compute_blind_index(plaintext, field_name, index_type) - - last4_field = f"{field_name}_last4" - if last4_field in self._fields: - # SECURITY: Store hashed partial index, not plaintext - vals[last4_field] = self._compute_blind_index(plaintext, field_name, "partial") - - return super().create(vals_list) - - def write(self, vals): - """Encrypt fields before write.""" - encrypted_fields = self._get_encrypted_fields() - if not encrypted_fields: - return super().write(vals) + Mutates ``vals`` in place. Clearing an encrypted field (falsy value) + also clears its blind index fields — otherwise the stale HMAC hashes + would keep matching searches after the PII itself has been removed. + Args: + vals: create/write values dict + encrypted_fields: field names configured for encryption + """ for field_name in encrypted_fields: - if field_name in vals and vals[field_name]: - plaintext = vals[field_name] + if field_name not in vals: + continue + index_field = f"{field_name}_index" + last4_field = f"{field_name}_last4" + plaintext = vals[field_name] + + if plaintext: # Encrypt vals[field_name] = self._encrypt_value(plaintext, field_name) @@ -362,15 +344,35 @@ def write(self, vals): index_type = self._get_index_type(field_name) # Compute blind indexes if fields exist - index_field = f"{field_name}_index" if index_field in self._fields: vals[index_field] = self._compute_blind_index(plaintext, field_name, index_type) - last4_field = f"{field_name}_last4" if last4_field in self._fields: # SECURITY: Store hashed partial index, not plaintext vals[last4_field] = self._compute_blind_index(plaintext, field_name, "partial") + else: + # Field is being cleared: clear the blind indexes too + if index_field in self._fields: + vals[index_field] = False + if last4_field in self._fields: + vals[last4_field] = False + + return vals + @api.model_create_multi + def create(self, vals_list): + """Encrypt fields before create.""" + encrypted_fields = self._get_encrypted_fields() + if encrypted_fields: + for vals in vals_list: + self._apply_encryption_to_vals(vals, encrypted_fields) + return super().create(vals_list) + + def write(self, vals): + """Encrypt fields before write.""" + encrypted_fields = self._get_encrypted_fields() + if encrypted_fields: + self._apply_encryption_to_vals(vals, encrypted_fields) return super().write(vals) def read(self, fields_list=None, load="_classic_read"): diff --git a/spp_pii_encryption/tests/test_encrypted_field_mixin.py b/spp_pii_encryption/tests/test_encrypted_field_mixin.py index e10ed1804..ca60725c0 100644 --- a/spp_pii_encryption/tests/test_encrypted_field_mixin.py +++ b/spp_pii_encryption/tests/test_encrypted_field_mixin.py @@ -2,6 +2,7 @@ """Tests for the encrypted field mixin with spp_key_management integration.""" import base64 +from unittest.mock import patch from odoo.tests.common import TransactionCase from odoo.tools import config, mute_logger @@ -155,3 +156,99 @@ def test_search_helpers_without_index_field(self): with mute_logger("odoo.addons.spp_pii_encryption.models.encrypted_field_mixin"): self.assertFalse(self.Mixin.search_by_blind_index("national_id", "123")) self.assertFalse(self.Mixin.search_by_partial("national_id", "0123")) + + def _mixin_with_index_fields(self): + """Return a patcher exposing national_id blind-index fields on the mixin. + + The vals-preparation helper only checks field *presence* in + ``self._fields``, so registering placeholder entries is enough to + exercise the index-maintenance branches on the abstract mixin. + """ + mixin_cls = type(self.Mixin) + fields_map = dict(mixin_cls._fields) + # Reuse a real Field object as placeholder in case anything iterates + # the mapping while the patch is active. + placeholder = next(iter(fields_map.values())) + fields_map["national_id_index"] = placeholder + fields_map["national_id_last4"] = placeholder + return patch.object(mixin_cls, "_fields", fields_map) + + def test_apply_encryption_to_vals_encrypts_and_indexes(self): + """Truthy values are encrypted and both blind indexes are computed.""" + mixin = self.Mixin + plaintext = "123-456-7890" + # Materialize key and salt records before patching _fields so no ORM + # writes happen while the placeholder mapping is active. + mixin._get_encryption_key("national_id") + mixin._get_index_salt("national_id") + + with self._mixin_with_index_fields(): + vals = {"national_id": plaintext, "other": "untouched"} + mixin._apply_encryption_to_vals(vals, ["national_id"]) + + self.assertNotEqual(vals["national_id"], plaintext) + self.assertEqual(mixin._decrypt_value(vals["national_id"], "national_id"), plaintext) + self.assertEqual( + vals["national_id_index"], + mixin._compute_blind_index(plaintext, "national_id", "exact"), + ) + self.assertEqual( + vals["national_id_last4"], + mixin._compute_blind_index(plaintext, "national_id", "partial"), + ) + self.assertEqual(vals["other"], "untouched") + + def test_apply_encryption_to_vals_clears_stale_indexes(self): + """Clearing an encrypted field must also clear its blind indexes. + + Otherwise the old HMAC hashes stay searchable after the PII itself + has been removed. + """ + mixin = self.Mixin + for cleared in (False, "", None): + with self._mixin_with_index_fields(): + vals = {"national_id": cleared} + mixin._apply_encryption_to_vals(vals, ["national_id"]) + self.assertFalse(vals["national_id"]) + self.assertIn("national_id_index", vals) + self.assertFalse(vals["national_id_index"]) + self.assertIn("national_id_last4", vals) + self.assertFalse(vals["national_id_last4"]) + + def test_write_encrypts_via_db_config(self): + """The write() override picks up spp.field.encryption.config rows. + + Uses the mixin's own reflected display_name char field so the whole + config-lookup -> vals-encryption path runs through the real ORM + override (write on an empty recordset is a no-op past that point). + """ + model = self.env["ir.model"]._get("spp.encrypted.field.mixin") + self.assertTrue(model, "abstract mixin should be reflected in ir.model") + field = self.env["ir.model.fields"]._get("spp.encrypted.field.mixin", "display_name") + self.assertTrue(field, "display_name should be reflected in ir.model.fields") + cfg = self.env["spp.field.encryption.config"].create( + { + "model_id": model.id, + "field_id": field.id, + } + ) + self.assertEqual(cfg.model_name, "spp.encrypted.field.mixin") + self.assertEqual(cfg.field_name, "display_name") + + self.assertEqual(self.Mixin._get_encrypted_fields(), ["display_name"]) + + vals = {"display_name": "secret-123"} + self.Mixin.browse().write(vals) + self.assertNotEqual(vals["display_name"], "secret-123") + self.assertEqual( + self.Mixin._decrypt_value(vals["display_name"], "display_name"), + "secret-123", + ) + + def test_apply_encryption_to_vals_untouched_field_stays_untouched(self): + """A field absent from vals is left alone entirely.""" + mixin = self.Mixin + with self._mixin_with_index_fields(): + vals = {"other": "abc"} + mixin._apply_encryption_to_vals(vals, ["national_id"]) + self.assertEqual(vals, {"other": "abc"}) From b2ab59062eb08c4e51736388a7f1535dc591c216 Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Tue, 25 Aug 2026 10:12:35 +0800 Subject: [PATCH 05/14] fix(spp_pii_encryption): use UTC now for audit history window create_date is stored in UTC; datetime.now() is server-local, skewing the get_user_access_history day window on non-UTC servers. Use fields.Datetime.now() and pass the datetime directly to the domain. --- spp_pii_encryption/models/audit_log.py | 7 ++++--- spp_pii_encryption/tests/test_audit_log.py | 18 ++++++++++++++++++ 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/spp_pii_encryption/models/audit_log.py b/spp_pii_encryption/models/audit_log.py index 395709463..16e699a50 100644 --- a/spp_pii_encryption/models/audit_log.py +++ b/spp_pii_encryption/models/audit_log.py @@ -148,17 +148,18 @@ def get_user_access_history(self, user_id=None, days=30, limit=1000): Returns: Recordset of audit log entries """ - from datetime import datetime, timedelta + from datetime import timedelta if user_id is None: user_id = self.env.user.id - cutoff = datetime.now() - timedelta(days=days) + # create_date is stored in UTC; compare against a UTC "now" + cutoff = fields.Datetime.now() - timedelta(days=days) return self.search( [ ("user_id", "=", user_id), - ("create_date", ">=", cutoff.strftime("%Y-%m-%d %H:%M:%S")), + ("create_date", ">=", cutoff), ], limit=limit, ) diff --git a/spp_pii_encryption/tests/test_audit_log.py b/spp_pii_encryption/tests/test_audit_log.py index f538bec8d..9e41e2c02 100644 --- a/spp_pii_encryption/tests/test_audit_log.py +++ b/spp_pii_encryption/tests/test_audit_log.py @@ -41,3 +41,21 @@ def test_get_user_access_history(self): history = self.Audit.get_user_access_history(self.env.user.id) self.assertTrue(history) self.assertTrue(all(h.user_id == self.env.user for h in history)) + + def test_get_user_access_history_window_excludes_old_entries(self): + """The days window is applied against UTC create_date.""" + recent = self.Audit.log_field_access("res.partner", 9, "phone", "reveal") + old = self.Audit.log_field_access("res.partner", 9, "phone", "export") + # Backdate the second entry beyond the queried window. + self.env.cr.execute( + "UPDATE spp_pii_audit_log SET create_date = create_date - interval '40 days' WHERE id = %s", + (old.id,), + ) + old.invalidate_recordset(["create_date"]) + + history = self.Audit.get_user_access_history(self.env.user.id, days=30) + self.assertIn(recent, history) + self.assertNotIn(old, history) + + wider = self.Audit.get_user_access_history(self.env.user.id, days=60) + self.assertIn(old, wider) From f333836d0f400abe8de7266e86d1b32dfb15e095 Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Tue, 25 Aug 2026 10:12:35 +0800 Subject: [PATCH 06/14] fix(spp_pii_encryption): remove base.group_system escalation from encryption admin group implied_ids GRANTS the implied group, so linking base.group_system would silently promote any Encryption Administrator to a full Settings/System admin - the same pattern removed from spp_dci and spp_key_management in #399. Imply base.group_user only; system admins get an explicit ACL row on the config model instead. --- spp_pii_encryption/security/ir.model.access.csv | 1 + spp_pii_encryption/security/security_groups.xml | 11 ++++++++--- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/spp_pii_encryption/security/ir.model.access.csv b/spp_pii_encryption/security/ir.model.access.csv index 14a3cff99..daaa78bdc 100644 --- a/spp_pii_encryption/security/ir.model.access.csv +++ b/spp_pii_encryption/security/ir.model.access.csv @@ -2,4 +2,5 @@ id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink access_pii_audit_log_admin,PII Audit Log Admin,model_spp_pii_audit_log,group_encryption_admin,1,0,0,0 access_pii_audit_log_system,PII Audit Log System,model_spp_pii_audit_log,base.group_system,1,0,1,0 access_field_encryption_config_admin,Field Encryption Config Admin,model_spp_field_encryption_config,group_encryption_admin,1,1,1,1 +access_field_encryption_config_system,Field Encryption Config System,model_spp_field_encryption_config,base.group_system,1,1,1,1 access_field_encryption_config_user,Field Encryption Config User,model_spp_field_encryption_config,base.group_user,1,0,0,0 diff --git a/spp_pii_encryption/security/security_groups.xml b/spp_pii_encryption/security/security_groups.xml index 0cec68e49..025c2f4c9 100644 --- a/spp_pii_encryption/security/security_groups.xml +++ b/spp_pii_encryption/security/security_groups.xml @@ -9,13 +9,18 @@ 10 - + Encryption Administrator Can manage encryption keys and rotate them. Highly privileged role. - + >Can configure field-level PII encryption and review PII access audit logs. Highly privileged role. + From 92fce95fb8b80e1eb713f1b38eb73c8d34c349bf Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Tue, 25 Aug 2026 10:12:42 +0800 Subject: [PATCH 07/14] fix(spp_pii_encryption): port masked_char widget to Odoo 19 web APIs useService('rpc') and useService('user') no longer exist in Odoo 19, so setup() would crash as soon as a view used the widget. Use the orm service for the audit call, the user object from @web/core/user for the group check, and _t() for user-facing strings. Also stop revealing the tail of a value shorter than or equal to the number of '#' slots - the whole value would have been displayed; the mask stays pattern-length by design so it does not leak value length. --- .../static/src/js/masked_field.js | 42 ++++++++++--------- 1 file changed, 23 insertions(+), 19 deletions(-) diff --git a/spp_pii_encryption/static/src/js/masked_field.js b/spp_pii_encryption/static/src/js/masked_field.js index cdf2317fb..01a1f09fd 100644 --- a/spp_pii_encryption/static/src/js/masked_field.js +++ b/spp_pii_encryption/static/src/js/masked_field.js @@ -4,6 +4,8 @@ import {registry} from "@web/core/registry"; import {CharField} from "@web/views/fields/char/char_field"; import {useState} from "@odoo/owl"; import {useService} from "@web/core/utils/hooks"; +import {user} from "@web/core/user"; +import {_t} from "@web/core/l10n/translation"; /** * MaskedCharField - A field widget that displays masked PII values @@ -32,9 +34,8 @@ export class MaskedCharField extends CharField { isRevealed: false, isLoading: false, }); - this.rpc = useService("rpc"); + this.orm = useService("orm"); this.notification = useService("notification"); - this.user = useService("user"); } get maskedValue() { @@ -72,9 +73,13 @@ export class MaskedCharField extends CharField { const patternChars = pattern.split(""); let result = ""; - // Count # in pattern to know how many chars to show from end + // Count # in pattern to know how many chars to show from end. + // The masked output is deliberately pattern-length, not value-length, + // so the mask does not leak how long the real value is. const hashCount = patternChars.filter((c) => c === "#").length; - let showFromEnd = hashCount; + // SECURITY: never reveal the tail of a value so short that the + // tail would be the entire value. + let showFromEnd = value.length > hashCount ? hashCount : 0; for (const patternChar of patternChars) { if (patternChar === "*") { @@ -82,11 +87,12 @@ export class MaskedCharField extends CharField { result += "•"; } else if (patternChar === "#") { // Show character from end - const showIdx = value.length - showFromEnd; - if (showIdx >= 0 && showIdx < value.length) { - result += value[showIdx]; + if (showFromEnd > 0) { + result += value[value.length - showFromEnd]; + showFromEnd--; + } else { + result += "•"; } - showFromEnd--; } else { // Literal character (like - or space) result += patternChar; @@ -106,7 +112,7 @@ export class MaskedCharField extends CharField { // Check if user can reveal const canReveal = await this.checkRevealPermission(); if (!canReveal) { - this.notification.add("You don't have permission to view this data.", { + this.notification.add(_t("You don't have permission to view this data."), { type: "warning", }); return; @@ -122,7 +128,7 @@ export class MaskedCharField extends CharField { this.state.isRevealed = true; } catch { - this.notification.add("Failed to reveal value.", { + this.notification.add(_t("Failed to reveal value."), { type: "danger", }); } finally { @@ -138,7 +144,7 @@ export class MaskedCharField extends CharField { } // Check if user has the required group - return await this.user.hasGroup(revealGroup); + return await user.hasGroup(revealGroup); } async auditRevealAction() { @@ -146,17 +152,15 @@ export class MaskedCharField extends CharField { const modelName = this.props.record.resModel; const fieldName = this.props.name; - await this.rpc("/web/dataset/call_kw", { - model: "spp.pii.audit.log", - method: "log_field_access", - args: [modelName, recordId, fieldName, "reveal"], - kwargs: {}, - }); + await this.orm.call("spp.pii.audit.log", "log_field_access", [ + modelName, + recordId, + fieldName, + "reveal", + ]); } } -MaskedCharField.template = "spp_pii_encryption.MaskedCharField"; - // Register the widget registry.category("fields").add("masked_char", { component: MaskedCharField, From 67bf0eea63bb6c47982911aba0a0124cc24a5b1f Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Tue, 25 Aug 2026 10:12:42 +0800 Subject: [PATCH 08/14] chore(spp_pii_encryption): drop unused spp_registry dependency spp_registry was referenced only in docstrings; the module is a library layer over base, spp_key_management and spp_security. --- spp_pii_encryption/__manifest__.py | 1 - 1 file changed, 1 deletion(-) diff --git a/spp_pii_encryption/__manifest__.py b/spp_pii_encryption/__manifest__.py index 8d0dc3206..9135aed82 100644 --- a/spp_pii_encryption/__manifest__.py +++ b/spp_pii_encryption/__manifest__.py @@ -13,7 +13,6 @@ "depends": [ "base", "spp_key_management", # Centralized key management - "spp_registry", "spp_security", ], "external_dependencies": { From 4541459a6220abf59142bfec313422f4ef63672f Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Tue, 25 Aug 2026 10:33:20 +0800 Subject: [PATCH 09/14] test(spp_pii_encryption): cover ORM overrides, search helpers and failure paths Codecov flagged 33 uncovered lines in encrypted_field_mixin.py - the create()/read() overrides, both blind-index search helpers, the encryption-failure branch and the Soundex empty guard, all paths that normally need a concrete inheriting model. Cover them by mocking the super() ORM boundary (BaseModel.create/read, the mixin's search) around the real mixin logic, with key/salt records materialized outside the patch scopes. 32 tests total. --- .../tests/test_encrypted_field_mixin.py | 118 +++++++++++++++++- 1 file changed, 112 insertions(+), 6 deletions(-) diff --git a/spp_pii_encryption/tests/test_encrypted_field_mixin.py b/spp_pii_encryption/tests/test_encrypted_field_mixin.py index ca60725c0..c500aa4f5 100644 --- a/spp_pii_encryption/tests/test_encrypted_field_mixin.py +++ b/spp_pii_encryption/tests/test_encrypted_field_mixin.py @@ -4,6 +4,7 @@ import base64 from unittest.mock import patch +from odoo import models from odoo.tests.common import TransactionCase from odoo.tools import config, mute_logger @@ -215,23 +216,31 @@ def test_apply_encryption_to_vals_clears_stale_indexes(self): self.assertIn("national_id_last4", vals) self.assertFalse(vals["national_id_last4"]) - def test_write_encrypts_via_db_config(self): - """The write() override picks up spp.field.encryption.config rows. + def _config_display_name_encryption(self): + """Configure encryption on the mixin's own reflected display_name field. - Uses the mixin's own reflected display_name char field so the whole - config-lookup -> vals-encryption path runs through the real ORM - override (write on an empty recordset is a no-op past that point). + display_name is the only char field the abstract mixin exposes, so it + is the one field a spp.field.encryption.config row can target without + a concrete inheriting model. """ model = self.env["ir.model"]._get("spp.encrypted.field.mixin") self.assertTrue(model, "abstract mixin should be reflected in ir.model") field = self.env["ir.model.fields"]._get("spp.encrypted.field.mixin", "display_name") self.assertTrue(field, "display_name should be reflected in ir.model.fields") - cfg = self.env["spp.field.encryption.config"].create( + return self.env["spp.field.encryption.config"].create( { "model_id": model.id, "field_id": field.id, } ) + + def test_write_encrypts_via_db_config(self): + """The write() override picks up spp.field.encryption.config rows. + + The whole config-lookup -> vals-encryption path runs through the real + ORM override (write on an empty recordset is a no-op past that point). + """ + cfg = self._config_display_name_encryption() self.assertEqual(cfg.model_name, "spp.encrypted.field.mixin") self.assertEqual(cfg.field_name, "display_name") @@ -245,6 +254,103 @@ def test_write_encrypts_via_db_config(self): "secret-123", ) + def test_create_encrypts_via_db_config(self): + """create() encrypts configured fields in every vals dict.""" + self._config_display_name_encryption() + mixin = self.Mixin + # Materialize the key before patching so no key records are created + # while BaseModel.create is mocked out. + mixin._get_encryption_key("display_name") + + vals_list = [{"display_name": "secret-A"}, {"display_name": ""}] + with patch.object(models.BaseModel, "create", return_value=mixin.browse()): + mixin.create(vals_list) + + self.assertNotEqual(vals_list[0]["display_name"], "secret-A") + self.assertEqual( + mixin._decrypt_value(vals_list[0]["display_name"], "display_name"), + "secret-A", + ) + # Falsy values are passed through unencrypted. + self.assertEqual(vals_list[1]["display_name"], "") + + def test_read_decrypts_via_db_config(self): + """read() decrypts configured fields and leaves other data as-is.""" + self._config_display_name_encryption() + mixin = self.Mixin + encrypted = mixin._encrypt_value("secret-R", "display_name") + + fake_rows = [ + {"id": 1, "display_name": encrypted}, + {"id": 2, "display_name": "not-ciphertext"}, + {"id": 3, "display_name": False}, + ] + with ( + patch.object(models.BaseModel, "read", return_value=fake_rows), + mute_logger("odoo.addons.spp_pii_encryption.models.encrypted_field_mixin"), + ): + result = mixin.browse().read(["display_name"]) + + self.assertEqual(result[0]["display_name"], "secret-R") + # Undecryptable data is left as-is (backwards compatibility with + # plaintext rows that predate encryption). + self.assertEqual(result[1]["display_name"], "not-ciphertext") + self.assertFalse(result[2]["display_name"]) + + def test_read_skips_unrequested_encrypted_fields(self): + """read() leaves the result untouched when no encrypted field is requested.""" + self._config_display_name_encryption() + fake_rows = [{"id": 1, "create_date": "2020-01-01"}] + with patch.object(models.BaseModel, "read", return_value=fake_rows): + result = self.Mixin.browse().read(["create_date"]) + self.assertEqual(result, [{"id": 1, "create_date": "2020-01-01"}]) + + def test_search_by_blind_index_builds_hashed_domain(self): + """search_by_blind_index searches on the HMAC, never the plaintext.""" + mixin = self.Mixin + mixin._get_index_salt("national_id") # materialize salt pre-patch + expected = mixin._compute_blind_index("123-456-7890", "national_id", "exact") + + with ( + self._mixin_with_index_fields(), + patch.object(type(mixin), "search", return_value=mixin.browse()) as mock_search, + ): + result = mixin.search_by_blind_index("national_id", "123-456-7890") + + self.assertFalse(result) + mock_search.assert_called_once_with([("national_id_index", "=", expected)]) + + def test_search_by_partial_builds_hashed_domain(self): + """search_by_partial hashes the search value before searching.""" + mixin = self.Mixin + mixin._get_index_salt("national_id") + expected = mixin._compute_blind_index("7890", "national_id", "partial") + + with ( + self._mixin_with_index_fields(), + patch.object(type(mixin), "search", return_value=mixin.browse()) as mock_search, + ): + result = mixin.search_by_partial("national_id", "7890") + + self.assertFalse(result) + mock_search.assert_called_once_with([("national_id_last4", "=", expected)]) + + def test_encrypt_value_failure_raises_sanitized_error(self): + """Encryption failures raise a ValueError without crypto internals.""" + mixin = self.Mixin + with ( + mute_logger("odoo.addons.spp_pii_encryption.models.encrypted_field_mixin"), + patch.object(type(mixin), "_get_encryption_key", side_effect=RuntimeError("boom")), + self.assertRaises(ValueError) as cm, + ): + mixin._encrypt_value("x", "national_id") + self.assertNotIn("boom", str(cm.exception)) + self.assertIn("national_id", str(cm.exception)) + + def test_soundex_empty_value(self): + """Empty input yields the neutral Soundex code.""" + self.assertEqual(self.Mixin._soundex(""), "0000") + def test_apply_encryption_to_vals_untouched_field_stays_untouched(self): """A field absent from vals is left alone entirely.""" mixin = self.Mixin From 49f80103a13d302a23ce0c56f1cbbb8cd721638f Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Tue, 25 Aug 2026 10:41:49 +0800 Subject: [PATCH 10/14] fix(spp_pii_encryption): harden mixin per adversarial review - rename read() param to 'fields' to match the Odoo 19 core signature (core callers like res.users use read(fields=...) by keyword) - sudo() the encryption-config lookups: it is a policy table and portal reads of a host model must not crash on its ACL - make the blind-index search helpers private - as public RPC methods they were a plaintext-confirmation / last-4-bucketing oracle - re-raise AccessError from _encrypt_value (missing key permission is a permission problem) and use UserError for genuine crypto failures - drop the dead _encryption_enabled field: nothing read it and it would materialize an unexplained column on every adopting model --- .../models/encrypted_field_mixin.py | 50 ++++++++++++------- .../tests/test_encrypted_field_mixin.py | 29 +++++++---- 2 files changed, 53 insertions(+), 26 deletions(-) diff --git a/spp_pii_encryption/models/encrypted_field_mixin.py b/spp_pii_encryption/models/encrypted_field_mixin.py index f4baf81d1..8f00d4457 100644 --- a/spp_pii_encryption/models/encrypted_field_mixin.py +++ b/spp_pii_encryption/models/encrypted_field_mixin.py @@ -31,7 +31,8 @@ def _get_encrypted_fields(self): from cryptography.hazmat.primitives.ciphers.aead import AESGCM -from odoo import api, fields, models +from odoo import _, api, models +from odoo.exceptions import AccessError, UserError _logger = logging.getLogger(__name__) @@ -62,12 +63,6 @@ def _get_encrypted_fields(self): _name = "spp.encrypted.field.mixin" _description = "Encrypted Field Mixin" - # Track which fields are currently encrypted (for migration) - _encryption_enabled = fields.Boolean( - default=True, - help="Whether encryption is enabled for this record", - ) - def _get_encrypted_fields(self): """Return list of field names that should be encrypted. @@ -82,7 +77,11 @@ def _get_encrypted_fields(self): # exists, so the presence test must compare against None. FieldConfig = self.env.get("spp.field.encryption.config") if FieldConfig is not None: - db_fields = FieldConfig.get_encrypted_fields(self._name) + # The config is a policy table, not user data: it must be + # readable regardless of the reader's own ACLs (portal/public + # reads of a host model would otherwise crash here). + # nosemgrep: odoo-sudo-without-context - policy lookup only, no user data is exposed + db_fields = FieldConfig.sudo().get_encrypted_fields(self._name) if db_fields: return db_fields # Fallback: no encryption configured @@ -133,7 +132,9 @@ def _get_index_type(self, field_name): """ FieldConfig = self.env.get("spp.field.encryption.config") if FieldConfig is not None: - config_type = FieldConfig.get_index_type(self._name, field_name) + # Same policy-table rationale as _get_encrypted_fields. + # nosemgrep: odoo-sudo-without-context - policy lookup only, no user data is exposed + config_type = FieldConfig.sudo().get_index_type(self._name, field_name) if config_type: return config_type return "exact" @@ -171,11 +172,18 @@ def _encrypt_value(self, value, field_name): encrypted = base64.b64encode(nonce + ciphertext).decode("ascii") return encrypted + except AccessError: + # The caller lacks key access: surface the real permission error + # instead of masking it as a technical failure. + raise except Exception: # SECURITY: Log error without crypto details that could aid attackers _logger.error("Encryption failed for %s.%s (details suppressed for security)", self._name, field_name) - raise ValueError( - f"Encryption failed for field '{field_name}'. Check system configuration and logs." + raise UserError( + _( + "Encryption failed for field '%(field)s'. Check system configuration and logs.", + field=field_name, + ) ) from None def _decrypt_value(self, encrypted_value, field_name): @@ -375,17 +383,17 @@ def write(self, vals): self._apply_encryption_to_vals(vals, encrypted_fields) return super().write(vals) - def read(self, fields_list=None, load="_classic_read"): + def read(self, fields=None, load="_classic_read"): """Decrypt fields after read.""" - result = super().read(fields_list, load) + result = super().read(fields, load) encrypted_fields = self._get_encrypted_fields() if not encrypted_fields: return result # Determine which encrypted fields are being read - if fields_list: - fields_to_decrypt = [f for f in encrypted_fields if f in fields_list] + if fields: + fields_to_decrypt = [f for f in encrypted_fields if f in fields] else: fields_to_decrypt = encrypted_fields @@ -406,9 +414,13 @@ def read(self, fields_list=None, load="_classic_read"): return result - def search_by_blind_index(self, field_name, search_value): + def _search_by_blind_index(self, field_name, search_value): """Search encrypted field using blind index. + Private on purpose: exposing this over RPC would hand any caller a + plaintext-confirmation oracle over low-entropy PII values. Consumer + models must wrap it with their own access policy. + Uses the configured index type for the field to ensure consistent index computation between write and search. @@ -433,9 +445,13 @@ def search_by_blind_index(self, field_name, search_value): blind_index = self._compute_blind_index(search_value, field_name, index_type) return self.search([(index_field, "=", blind_index)]) - def search_by_partial(self, field_name, last_chars): + def _search_by_partial(self, field_name, last_chars): """Search encrypted field by last N characters. + Private on purpose: over RPC this would let a caller partition all + records into last-4 buckets and enumerate matches. Consumer models + must wrap it with their own access policy. + Uses blind index for secure partial matching. Args: diff --git a/spp_pii_encryption/tests/test_encrypted_field_mixin.py b/spp_pii_encryption/tests/test_encrypted_field_mixin.py index c500aa4f5..09276ce1e 100644 --- a/spp_pii_encryption/tests/test_encrypted_field_mixin.py +++ b/spp_pii_encryption/tests/test_encrypted_field_mixin.py @@ -5,6 +5,7 @@ from unittest.mock import patch from odoo import models +from odoo.exceptions import AccessError, UserError from odoo.tests.common import TransactionCase from odoo.tools import config, mute_logger @@ -155,8 +156,8 @@ def test_decrypt_invalid_returns_none(self): def test_search_helpers_without_index_field(self): """Search helpers return an empty recordset when the index column is absent.""" with mute_logger("odoo.addons.spp_pii_encryption.models.encrypted_field_mixin"): - self.assertFalse(self.Mixin.search_by_blind_index("national_id", "123")) - self.assertFalse(self.Mixin.search_by_partial("national_id", "0123")) + self.assertFalse(self.Mixin._search_by_blind_index("national_id", "123")) + self.assertFalse(self.Mixin._search_by_partial("national_id", "0123")) def _mixin_with_index_fields(self): """Return a patcher exposing national_id blind-index fields on the mixin. @@ -289,7 +290,8 @@ def test_read_decrypts_via_db_config(self): patch.object(models.BaseModel, "read", return_value=fake_rows), mute_logger("odoo.addons.spp_pii_encryption.models.encrypted_field_mixin"), ): - result = mixin.browse().read(["display_name"]) + # Keyword call mirrors core callers like res.users read(fields=...) + result = mixin.browse().read(fields=["display_name"]) self.assertEqual(result[0]["display_name"], "secret-R") # Undecryptable data is left as-is (backwards compatibility with @@ -306,7 +308,7 @@ def test_read_skips_unrequested_encrypted_fields(self): self.assertEqual(result, [{"id": 1, "create_date": "2020-01-01"}]) def test_search_by_blind_index_builds_hashed_domain(self): - """search_by_blind_index searches on the HMAC, never the plaintext.""" + """_search_by_blind_index searches on the HMAC, never the plaintext.""" mixin = self.Mixin mixin._get_index_salt("national_id") # materialize salt pre-patch expected = mixin._compute_blind_index("123-456-7890", "national_id", "exact") @@ -315,13 +317,13 @@ def test_search_by_blind_index_builds_hashed_domain(self): self._mixin_with_index_fields(), patch.object(type(mixin), "search", return_value=mixin.browse()) as mock_search, ): - result = mixin.search_by_blind_index("national_id", "123-456-7890") + result = mixin._search_by_blind_index("national_id", "123-456-7890") self.assertFalse(result) mock_search.assert_called_once_with([("national_id_index", "=", expected)]) def test_search_by_partial_builds_hashed_domain(self): - """search_by_partial hashes the search value before searching.""" + """_search_by_partial hashes the search value before searching.""" mixin = self.Mixin mixin._get_index_salt("national_id") expected = mixin._compute_blind_index("7890", "national_id", "partial") @@ -330,23 +332,32 @@ def test_search_by_partial_builds_hashed_domain(self): self._mixin_with_index_fields(), patch.object(type(mixin), "search", return_value=mixin.browse()) as mock_search, ): - result = mixin.search_by_partial("national_id", "7890") + result = mixin._search_by_partial("national_id", "7890") self.assertFalse(result) mock_search.assert_called_once_with([("national_id_last4", "=", expected)]) def test_encrypt_value_failure_raises_sanitized_error(self): - """Encryption failures raise a ValueError without crypto internals.""" + """Encryption failures raise a UserError without crypto internals.""" mixin = self.Mixin with ( mute_logger("odoo.addons.spp_pii_encryption.models.encrypted_field_mixin"), patch.object(type(mixin), "_get_encryption_key", side_effect=RuntimeError("boom")), - self.assertRaises(ValueError) as cm, + self.assertRaises(UserError) as cm, ): mixin._encrypt_value("x", "national_id") self.assertNotIn("boom", str(cm.exception)) self.assertIn("national_id", str(cm.exception)) + def test_encrypt_value_propagates_access_error(self): + """A missing key permission surfaces as AccessError, not a crypto error.""" + mixin = self.Mixin + with ( + patch.object(type(mixin), "_get_encryption_key", side_effect=AccessError("no key for you")), + self.assertRaises(AccessError), + ): + mixin._encrypt_value("x", "national_id") + def test_soundex_empty_value(self): """Empty input yields the neutral Soundex code.""" self.assertEqual(self.Mixin._soundex(""), "0000") From f31e5d3ddd3bfa11fd3f1678919fdb589c650412 Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Tue, 25 Aug 2026 10:41:49 +0800 Subject: [PATCH 11/14] security(spp_pii_encryption): validate log_field_access targets The method is RPC-reachable and creates rows with sudo(), so any authenticated user (portal included) could flood or forge the audit trail with entries pointing at arbitrary model/record/field strings. Now the model and field must exist, the record must exist, and the caller must be able to read it. Rate limiting and retention are follow-ups. --- spp_pii_encryption/models/audit_log.py | 34 +++++++++++++++++- spp_pii_encryption/tests/test_audit_log.py | 41 ++++++++++++++++------ 2 files changed, 64 insertions(+), 11 deletions(-) diff --git a/spp_pii_encryption/models/audit_log.py b/spp_pii_encryption/models/audit_log.py index 16e699a50..0476f2f7e 100644 --- a/spp_pii_encryption/models/audit_log.py +++ b/spp_pii_encryption/models/audit_log.py @@ -1,6 +1,7 @@ import logging -from odoo import api, fields, models +from odoo import _, api, fields, models +from odoo.exceptions import ValidationError _logger = logging.getLogger(__name__) @@ -88,7 +89,38 @@ def log_field_access(self, model_name, record_id, field_name, action, reason=Non Returns: The created audit log record + + Raises: + ValidationError: if the target model, record or field does not + exist (prevents forged audit entries pointing at nothing) + AccessError: if the caller cannot read the target record """ + # This method is RPC-reachable and creates rows with sudo(), so the + # target must be validated: only real, readable records with real + # fields may be logged against. Otherwise any authenticated user + # could flood the audit trail with plausible-looking forgeries. + target_model = self.env.get(model_name) + if target_model is None: + raise ValidationError(_("Cannot log PII access: unknown model '%(model)s'.", model=model_name)) + if field_name not in target_model._fields: + raise ValidationError( + _( + "Cannot log PII access: field '%(field)s' does not exist on '%(model)s'.", + field=field_name, + model=model_name, + ) + ) + target_record = target_model.browse(record_id).exists() + if not target_record: + raise ValidationError( + _( + "Cannot log PII access: record %(record_id)s does not exist on '%(model)s'.", + record_id=record_id, + model=model_name, + ) + ) + target_record.check_access("read") + # Get IP and user agent from request if available ip_address = None user_agent = None diff --git a/spp_pii_encryption/tests/test_audit_log.py b/spp_pii_encryption/tests/test_audit_log.py index 9e41e2c02..f13d6154c 100644 --- a/spp_pii_encryption/tests/test_audit_log.py +++ b/spp_pii_encryption/tests/test_audit_log.py @@ -1,6 +1,7 @@ # Part of OpenSPP. See LICENSE file for full copyright and licensing details. """Tests for spp.pii.audit.log (PII access audit trail).""" +from odoo.exceptions import ValidationError from odoo.tests.common import TransactionCase @@ -11,13 +12,15 @@ class TestPIIAuditLog(TransactionCase): def setUpClass(cls): super().setUpClass() cls.Audit = cls.env["spp.pii.audit.log"] + cls.partner_a = cls.env["res.partner"].create({"name": "Audit Target A"}) + cls.partner_b = cls.env["res.partner"].create({"name": "Audit Target B"}) def test_log_field_access(self): - log = self.Audit.log_field_access("res.partner", 1, "name", "reveal", reason="support call") + log = self.Audit.log_field_access("res.partner", self.partner_a.id, "name", "reveal", reason="support call") self.assertTrue(log) self.assertEqual(log.model_name, "res.partner") - self.assertEqual(log.record_id, 1) + self.assertEqual(log.record_id, self.partner_a.id) self.assertEqual(log.field_name, "name") self.assertEqual(log.action, "reveal") self.assertEqual(log.reason, "support call") @@ -25,18 +28,36 @@ def test_log_field_access(self): # display_name is computed from action/model/field. self.assertEqual(log.display_name, "reveal res.partner.name") + def test_log_field_access_rejects_unknown_model(self): + """Forged entries pointing at nonexistent models are refused.""" + with self.assertRaises(ValidationError): + self.Audit.log_field_access("no.such.model", 1, "name", "reveal") + + def test_log_field_access_rejects_unknown_field(self): + """Forged entries pointing at nonexistent fields are refused.""" + with self.assertRaises(ValidationError): + self.Audit.log_field_access("res.partner", self.partner_a.id, "no_such_field", "reveal") + + def test_log_field_access_rejects_missing_record(self): + """Forged entries pointing at nonexistent records are refused.""" + missing_id = self.partner_b.id + self.partner_b.unlink() + with self.assertRaises(ValidationError): + self.Audit.log_field_access("res.partner", missing_id, "name", "reveal") + def test_get_access_history(self): - self.Audit.log_field_access("res.partner", 7, "email", "reveal") - self.Audit.log_field_access("res.partner", 7, "email", "export") + other = self.env["res.partner"].create({"name": "Audit Target C"}) + self.Audit.log_field_access("res.partner", self.partner_a.id, "email", "reveal") + self.Audit.log_field_access("res.partner", self.partner_a.id, "email", "export") # An entry for a different record should be excluded. - self.Audit.log_field_access("res.partner", 8, "email", "reveal") + self.Audit.log_field_access("res.partner", other.id, "email", "reveal") - history = self.Audit.get_access_history("res.partner", 7) + history = self.Audit.get_access_history("res.partner", self.partner_a.id) self.assertEqual(len(history), 2) - self.assertTrue(all(h.record_id == 7 for h in history)) + self.assertTrue(all(h.record_id == self.partner_a.id for h in history)) def test_get_user_access_history(self): - self.Audit.log_field_access("res.partner", 9, "phone", "reveal") + self.Audit.log_field_access("res.partner", self.partner_a.id, "phone", "reveal") history = self.Audit.get_user_access_history(self.env.user.id) self.assertTrue(history) @@ -44,8 +65,8 @@ def test_get_user_access_history(self): def test_get_user_access_history_window_excludes_old_entries(self): """The days window is applied against UTC create_date.""" - recent = self.Audit.log_field_access("res.partner", 9, "phone", "reveal") - old = self.Audit.log_field_access("res.partner", 9, "phone", "export") + recent = self.Audit.log_field_access("res.partner", self.partner_a.id, "phone", "reveal") + old = self.Audit.log_field_access("res.partner", self.partner_a.id, "phone", "export") # Backdate the second entry beyond the queried window. self.env.cr.execute( "UPDATE spp_pii_audit_log SET create_date = create_date - interval '40 days' WHERE id = %s", From a43569f552a443703ce4ce3a1c1bc05c74532b61 Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Tue, 25 Aug 2026 10:41:49 +0800 Subject: [PATCH 12/14] fix(spp_pii_encryption): reject size-limited and translated fields in config Odoo silently truncates Char values to their size limit; a truncated ciphertext can never be decrypted, so encrypting such a field destroys the plaintext on the first write with no error. Translated fields store per-language jsonb values and would fragment the ciphertext. --- .../models/field_encryption_config.py | 29 +++++++++++++++++-- .../tests/test_field_encryption_config.py | 26 +++++++++++++++++ 2 files changed, 53 insertions(+), 2 deletions(-) diff --git a/spp_pii_encryption/models/field_encryption_config.py b/spp_pii_encryption/models/field_encryption_config.py index 4ff88564e..a097271cb 100644 --- a/spp_pii_encryption/models/field_encryption_config.py +++ b/spp_pii_encryption/models/field_encryption_config.py @@ -128,9 +128,11 @@ def _compute_index_field_name(self): @api.constrains("field_id") def _check_field_type(self): - """Ensure only char and text fields can be encrypted.""" + """Ensure only unconstrained char and text fields can be encrypted.""" for record in self: - if record.field_id and record.field_id.ttype not in ("char", "text"): + if not record.field_id: + continue + if record.field_id.ttype not in ("char", "text"): raise ValidationError( _( "Only Char and Text fields can be encrypted. '%(field)s' is a %(ttype)s field.", @@ -138,6 +140,29 @@ def _check_field_type(self): ttype=record.field_id.ttype, ) ) + if record.field_id.size: + # Odoo silently truncates Char values to `size`; a truncated + # ciphertext can never be decrypted, so the plaintext would + # be destroyed with no error on the very first write. + raise ValidationError( + _( + "Field '%(field)s' has a size limit (%(size)s). Encrypted values are" + " longer than their plaintext and would be silently truncated," + " destroying the data. Only unlimited Char/Text fields can be encrypted.", + field=record.field_id.name, + size=record.field_id.size, + ) + ) + if record.field_id.translate: + # A translated field stores one value per language in a jsonb + # column; transparent encryption would fragment the ciphertext + # across languages and lose values on language switch. + raise ValidationError( + _( + "Field '%(field)s' is translatable and cannot be encrypted.", + field=record.field_id.name, + ) + ) @api.model def get_encrypted_fields(self, model_name): diff --git a/spp_pii_encryption/tests/test_field_encryption_config.py b/spp_pii_encryption/tests/test_field_encryption_config.py index 61993997e..b6ee8e517 100644 --- a/spp_pii_encryption/tests/test_field_encryption_config.py +++ b/spp_pii_encryption/tests/test_field_encryption_config.py @@ -86,3 +86,29 @@ def test_non_text_field_rejected(self): "field_id": self.bool_field.id, } ) + + def test_size_limited_field_rejected(self): + """Char fields with a size limit would silently truncate ciphertext.""" + country_model = self.env["ir.model"]._get("res.country") + code_field = self.env["ir.model.fields"]._get("res.country", "code") + self.assertTrue(code_field.size, "res.country.code should have a size limit") + with self.assertRaises(ValidationError): + self.Config.create( + { + "model_id": country_model.id, + "field_id": code_field.id, + } + ) + + def test_translated_field_rejected(self): + """Translated fields store per-language values and cannot be encrypted.""" + country_model = self.env["ir.model"]._get("res.country") + name_field = self.env["ir.model.fields"]._get("res.country", "name") + self.assertTrue(name_field.translate, "res.country.name should be translatable") + with self.assertRaises(ValidationError): + self.Config.create( + { + "model_id": country_model.id, + "field_id": name_field.id, + } + ) From d65fc20325ad8a2d588882abff81a3ae93b50444 Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Tue, 25 Aug 2026 10:41:49 +0800 Subject: [PATCH 13/14] fix(spp_pii_encryption): reachable menu, tighter config ACL, accurate docs - admit base.group_system to the PII Encryption menu: nobody holds group_encryption_admin on a fresh install, so the UI was unreachable (menu visibility only - no implied_ids escalation) - drop the base.group_user read ACL on the config model: internal users could enumerate the PII field map, and the mixin no longer needs it since its config lookup is sudo'd - DESCRIPTION.md: correct the dependency list, menu paths, audit-scope claim, and document the private search helpers --- spp_pii_encryption/readme/DESCRIPTION.md | 17 ++++++++--------- spp_pii_encryption/security/ir.model.access.csv | 1 - spp_pii_encryption/views/menu.xml | 2 +- 3 files changed, 9 insertions(+), 11 deletions(-) diff --git a/spp_pii_encryption/readme/DESCRIPTION.md b/spp_pii_encryption/readme/DESCRIPTION.md index 4931908e5..8c009b8b4 100644 --- a/spp_pii_encryption/readme/DESCRIPTION.md +++ b/spp_pii_encryption/readme/DESCRIPTION.md @@ -5,7 +5,7 @@ Field-level encryption for PII data using AES-256-GCM with searchable blind inde - Encrypt char/text fields transparently using AES-256-GCM authenticated encryption - Search encrypted data via blind indexes without decryption (exact, partial, or phonetic matching) - Configure field encryption through UI instead of code changes -- Audit all PII field access (reveal, export, decrypt, modify, delete) with IP and user agent tracking +- Audit logging of PII field access events (reveal, export, decrypt, modify, delete) with IP and user agent tracking; logging is invoked by cooperating UI widgets and code paths, it does not intercept every read ### Key Models @@ -19,7 +19,7 @@ Field-level encryption for PII data using AES-256-GCM with searchable blind inde After installing: -1. Navigate to **Settings > Key Management > PII Encryption > Field Configuration** +1. Navigate to **Key Management > PII Encryption > Field Configuration** 2. Create a new configuration selecting the model and field to encrypt 3. Choose the blind index type: Exact (full normalized match), Partial (last 4 characters), or Phonetic (Soundex for names) 4. Enable encryption and blind index options @@ -28,16 +28,15 @@ Bulk migration of existing plaintext data (scan, dry-run, backup, rollback) is p ### UI Location -- **Configuration**: Settings > Key Management > PII Encryption > Field Configuration -- **Audit Log**: Settings > Key Management > PII Encryption > Audit Log +- **Configuration**: Key Management > PII Encryption > Field Configuration +- **Audit Log**: Key Management > PII Encryption > Audit Log ### Security | Group | Access | | -------------------------------------------- | -------------------------------------------------------------- | | `spp_pii_encryption.group_encryption_admin` | Full CRUD on field configuration; Read on audit log | -| `base.group_system` | Read/Create on audit logs | -| `base.group_user` | Read field encryption configuration | +| `base.group_system` | Full CRUD on field configuration; Read/Create on audit logs | ### Extension Points @@ -45,9 +44,9 @@ Bulk migration of existing plaintext data (scan, dry-run, backup, rollback) is p - Implement `_get_encrypted_fields()` to specify which fields to encrypt (or configure via UI) - Override `_get_encryption_key(field_name)` to customize key retrieval per field - Override `_normalize_for_index(value, index_type)` to customize blind index normalization -- Use `search_by_blind_index(field_name, search_value)` to search encrypted fields -- Call `log_field_access(model, record_id, field, action, reason)` to audit PII access +- Use `_search_by_blind_index(field_name, search_value)` from server-side code to search encrypted fields (deliberately not RPC-exposed; wrap it with your own access policy) +- Call `log_field_access(model, record_id, field, action, reason)` to audit PII access (the target record must exist and be readable by the caller) ### Dependencies -`base`, `spp_key_management`, `spp_registry`, `spp_security` +`base`, `spp_key_management`, `spp_security` diff --git a/spp_pii_encryption/security/ir.model.access.csv b/spp_pii_encryption/security/ir.model.access.csv index daaa78bdc..dd7cc9424 100644 --- a/spp_pii_encryption/security/ir.model.access.csv +++ b/spp_pii_encryption/security/ir.model.access.csv @@ -3,4 +3,3 @@ access_pii_audit_log_admin,PII Audit Log Admin,model_spp_pii_audit_log,group_enc access_pii_audit_log_system,PII Audit Log System,model_spp_pii_audit_log,base.group_system,1,0,1,0 access_field_encryption_config_admin,Field Encryption Config Admin,model_spp_field_encryption_config,group_encryption_admin,1,1,1,1 access_field_encryption_config_system,Field Encryption Config System,model_spp_field_encryption_config,base.group_system,1,1,1,1 -access_field_encryption_config_user,Field Encryption Config User,model_spp_field_encryption_config,base.group_user,1,0,0,0 diff --git a/spp_pii_encryption/views/menu.xml b/spp_pii_encryption/views/menu.xml index c75e4a2a0..181fbedbd 100644 --- a/spp_pii_encryption/views/menu.xml +++ b/spp_pii_encryption/views/menu.xml @@ -8,7 +8,7 @@ name="PII Encryption" parent="spp_key_management.menu_key_management_root" sequence="20" - groups="group_encryption_admin" + groups="group_encryption_admin,base.group_system" /> Date: Tue, 25 Aug 2026 10:48:10 +0800 Subject: [PATCH 14/14] docs(spp_pii_encryption): apply CI-generated README/index.html Applied verbatim from the pre-commit run's --show-diff-on-failure output (run 32802395229); the local generator is not byte-reproducible against CI's pinned hook env. --- spp_pii_encryption/README.rst | 30 +++++++++--------- .../static/description/index.html | 31 ++++++++++--------- 2 files changed, 32 insertions(+), 29 deletions(-) diff --git a/spp_pii_encryption/README.rst b/spp_pii_encryption/README.rst index 5d4eb6647..7d0b39a0b 100644 --- a/spp_pii_encryption/README.rst +++ b/spp_pii_encryption/README.rst @@ -35,8 +35,10 @@ Key Capabilities - Search encrypted data via blind indexes without decryption (exact, partial, or phonetic matching) - Configure field encryption through UI instead of code changes -- Audit all PII field access (reveal, export, decrypt, modify, delete) - with IP and user agent tracking +- Audit logging of PII field access events (reveal, export, decrypt, + modify, delete) with IP and user agent tracking; logging is invoked by + cooperating UI widgets and code paths, it does not intercept every + read Key Models ~~~~~~~~~~ @@ -60,8 +62,7 @@ Configuration After installing: -1. Navigate to **Settings > Key Management > PII Encryption > Field - Configuration** +1. Navigate to **Key Management > PII Encryption > Field Configuration** 2. Create a new configuration selecting the model and field to encrypt 3. Choose the blind index type: Exact (full normalized match), Partial (last 4 characters), or Phonetic (Soundex for names) @@ -74,9 +75,9 @@ module. UI Location ~~~~~~~~~~~ -- **Configuration**: Settings > Key Management > PII Encryption > Field +- **Configuration**: Key Management > PII Encryption > Field Configuration -- **Audit Log**: Settings > Key Management > PII Encryption > Audit Log +- **Audit Log**: Key Management > PII Encryption > Audit Log Security ~~~~~~~~ @@ -87,10 +88,9 @@ Security | ``spp_pii_encryption.group_encryption_admin`` | Full CRUD on field | | | configuration; Read on audit log | +-----------------------------------------------+----------------------------------+ -| ``base.group_system`` | Read/Create on audit logs | -+-----------------------------------------------+----------------------------------+ -| ``base.group_user`` | Read field encryption | -| | configuration | +| ``base.group_system`` | Full CRUD on field | +| | configuration; Read/Create on | +| | audit logs | +-----------------------------------------------+----------------------------------+ Extension Points @@ -104,15 +104,17 @@ Extension Points retrieval per field - Override ``_normalize_for_index(value, index_type)`` to customize blind index normalization -- Use ``search_by_blind_index(field_name, search_value)`` to search - encrypted fields +- Use ``_search_by_blind_index(field_name, search_value)`` from + server-side code to search encrypted fields (deliberately not + RPC-exposed; wrap it with your own access policy) - Call ``log_field_access(model, record_id, field, action, reason)`` to - audit PII access + audit PII access (the target record must exist and be readable by the + caller) Dependencies ~~~~~~~~~~~~ -``base``, ``spp_key_management``, ``spp_registry``, ``spp_security`` +``base``, ``spp_key_management``, ``spp_security`` .. IMPORTANT:: This is an alpha version, the data model and design can change at any time without warning. diff --git a/spp_pii_encryption/static/description/index.html b/spp_pii_encryption/static/description/index.html index 7b2739805..cc58ec698 100644 --- a/spp_pii_encryption/static/description/index.html +++ b/spp_pii_encryption/static/description/index.html @@ -382,8 +382,10 @@

Key Capabilities

  • Search encrypted data via blind indexes without decryption (exact, partial, or phonetic matching)
  • Configure field encryption through UI instead of code changes
  • -
  • Audit all PII field access (reveal, export, decrypt, modify, delete) -with IP and user agent tracking
  • +
  • Audit logging of PII field access events (reveal, export, decrypt, +modify, delete) with IP and user agent tracking; logging is invoked by +cooperating UI widgets and code paths, it does not intercept every +read
  • @@ -419,8 +421,7 @@

    Key Models

    Configuration

    After installing:

      -
    1. Navigate to Settings > Key Management > PII Encryption > Field -Configuration
    2. +
    3. Navigate to Key Management > PII Encryption > Field Configuration
    4. Create a new configuration selecting the model and field to encrypt
    5. Choose the blind index type: Exact (full normalized match), Partial (last 4 characters), or Phonetic (Soundex for names)
    6. @@ -433,9 +434,9 @@

      Configuration

      UI Location

        -
      • Configuration: Settings > Key Management > PII Encryption > Field +
      • Configuration: Key Management > PII Encryption > Field Configuration
      • -
      • Audit Log: Settings > Key Management > PII Encryption > Audit Log
      • +
      • Audit Log: Key Management > PII Encryption > Audit Log
      @@ -456,11 +457,9 @@

      Security

      configuration; Read on audit log base.group_system -Read/Create on audit logs - -base.group_user -Read field encryption -configuration +Full CRUD on field +configuration; Read/Create on +audit logs @@ -476,15 +475,17 @@

      Extension Points

      retrieval per field
    7. Override _normalize_for_index(value, index_type) to customize blind index normalization
    8. -
    9. Use search_by_blind_index(field_name, search_value) to search -encrypted fields
    10. +
    11. Use _search_by_blind_index(field_name, search_value) from +server-side code to search encrypted fields (deliberately not +RPC-exposed; wrap it with your own access policy)
    12. Call log_field_access(model, record_id, field, action, reason) to -audit PII access
    13. +audit PII access (the target record must exist and be readable by the +caller)

      Dependencies

      -

      base, spp_key_management, spp_registry, spp_security

      +

      base, spp_key_management, spp_security

      Important

      This is an alpha version, the data model and design can change at any time without warning.