diff --git a/spp_pii_encryption/README.rst b/spp_pii_encryption/README.rst new file mode 100644 index 000000000..7d0b39a0b --- /dev/null +++ b/spp_pii_encryption/README.rst @@ -0,0 +1,162 @@ +====================== +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 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 +~~~~~~~~~~ + ++---------------------------------+------------------------------------+ +| 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 **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**: 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`` | Full CRUD on field | +| | configuration; Read/Create on | +| | audit logs | ++-----------------------------------------------+----------------------------------+ + +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)`` 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_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-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 maintainers: + +|maintainer-jeremi| |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..9135aed82 --- /dev/null +++ b/spp_pii_encryption/__manifest__.py @@ -0,0 +1,42 @@ +# 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_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..0476f2f7e --- /dev/null +++ b/spp_pii_encryption/models/audit_log.py @@ -0,0 +1,197 @@ +import logging + +from odoo import _, api, fields, models +from odoo.exceptions import ValidationError + +_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 + + 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 + + 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 timedelta + + if user_id is None: + user_id = self.env.user.id + + # 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), + ], + 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..8f00d4457 --- /dev/null +++ b/spp_pii_encryption/models/encrypted_field_mixin.py @@ -0,0 +1,475 @@ +# 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, models +from odoo.exceptions import AccessError, UserError + +_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" + + 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. + # 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 is not None: + # 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 + 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 is not None: + # 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" + + 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 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 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): + """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] + + def _apply_encryption_to_vals(self, vals, encrypted_fields): + """Encrypt configured fields in a vals dict and maintain blind indexes. + + 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 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) + + # Get configured index type + index_type = self._get_index_type(field_name) + + # Compute blind indexes if fields exist + if index_field in self._fields: + vals[index_field] = self._compute_blind_index(plaintext, field_name, index_type) + + 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=None, load="_classic_read"): + """Decrypt fields after read.""" + 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: + fields_to_decrypt = [f for f in encrypted_fields if f in fields] + 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. + + 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. + + 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. + + 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: + 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..a097271cb --- /dev/null +++ b/spp_pii_encryption/models/field_encryption_config.py @@ -0,0 +1,247 @@ +# 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 unconstrained char and text fields can be encrypted.""" + for record in self: + 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.", + field=record.field_id.name, + 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): + """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..8c009b8b4 --- /dev/null +++ b/spp_pii_encryption/readme/DESCRIPTION.md @@ -0,0 +1,52 @@ +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 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 + +| 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 **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**: 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` | Full CRUD on field configuration; Read/Create on audit logs | + +### 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)` 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_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..dd7cc9424 --- /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_system,Field Encryption Config System,model_spp_field_encryption_config,base.group_system,1,1,1,1 diff --git a/spp_pii_encryption/security/security_groups.xml b/spp_pii_encryption/security/security_groups.xml new file mode 100644 index 000000000..025c2f4c9 --- /dev/null +++ b/spp_pii_encryption/security/security_groups.xml @@ -0,0 +1,26 @@ + + + + + + + Administrator + + 10 + + + + + Encryption Administrator + + Can configure field-level PII encryption and review PII access audit logs. 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 000000000..c7dbdaaf1 Binary files /dev/null and b/spp_pii_encryption/static/description/icon.png differ diff --git a/spp_pii_encryption/static/description/index.html b/spp_pii_encryption/static/description/index.html new file mode 100644 index 000000000..cc58ec698 --- /dev/null +++ b/spp_pii_encryption/static/description/index.html @@ -0,0 +1,532 @@ + + + + + +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 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

+ ++++ + + + + + + + + + + + + + + + + +
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 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: Key Management > PII Encryption > Field +Configuration
  • +
  • Audit Log: Key Management > PII Encryption > Audit Log
  • +
+
+
+

Security

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

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) 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_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 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 new file mode 100644 index 000000000..01a1f09fd --- /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 {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 + * 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.orm = useService("orm"); + this.notification = useService("notification"); + } + + 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 patternChars = pattern.split(""); + let result = ""; + + // 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; + // 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 === "*") { + // Mask character + result += "•"; + } else if (patternChar === "#") { + // Show character from end + if (showFromEnd > 0) { + result += value[value.length - showFromEnd]; + showFromEnd--; + } else { + result += "•"; + } + } 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(_t("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 { + this.notification.add(_t("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 user.hasGroup(revealGroup); + } + + async auditRevealAction() { + const recordId = this.props.record.resId; + const modelName = this.props.record.resModel; + const fieldName = this.props.name; + + await this.orm.call("spp.pii.audit.log", "log_field_access", [ + modelName, + recordId, + fieldName, + "reveal", + ]); + } +} + +// 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..1ccefd13b --- /dev/null +++ b/spp_pii_encryption/tests/__init__.py @@ -0,0 +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..f13d6154c --- /dev/null +++ b/spp_pii_encryption/tests/test_audit_log.py @@ -0,0 +1,82 @@ +# 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 + + +class TestPIIAuditLog(TransactionCase): + """log_field_access and history query helpers.""" + + @classmethod + 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", self.partner_a.id, "name", "reveal", reason="support call") + + self.assertTrue(log) + self.assertEqual(log.model_name, "res.partner") + 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") + 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_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): + 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", other.id, "email", "reveal") + + history = self.Audit.get_access_history("res.partner", self.partner_a.id) + self.assertEqual(len(history), 2) + 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", self.partner_a.id, "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)) + + 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", 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", + (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) 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..09276ce1e --- /dev/null +++ b/spp_pii_encryption/tests/test_encrypted_field_mixin.py @@ -0,0 +1,371 @@ +# 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 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 + + +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) + + 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")) + + 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 _config_display_name_encryption(self): + """Configure encryption on the mixin's own reflected display_name field. + + 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") + 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") + + 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_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"), + ): + # 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 + # 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 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(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") + + 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"}) 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..b6ee8e517 --- /dev/null +++ b/spp_pii_encryption/tests/test_field_encryption_config.py @@ -0,0 +1,114 @@ +# 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, + } + ) + + 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, + } + ) 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..181fbedbd --- /dev/null +++ b/spp_pii_encryption/views/menu.xml @@ -0,0 +1,29 @@ + + + + + + + + + + +