From 1c7fcfec17fcdbc165a7dd2bf0bcef24ef164767 Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Tue, 25 Aug 2026 13:26:56 +0800 Subject: [PATCH 1/7] feat(spp_pii_encryption): re-add encryption migration wizard (scan/dry-run/migrate) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ported from openspp-modules, unblocked by spp_data_classification (is_pii): the scan is driven by the classification registry. Deliberate changes from the source: - No in-app rollback and no plaintext backup table. The rollback relied on a skip_encryption context no mixin implements (it re-encrypted on write), and a persistent plaintext copy of the values being encrypted contradicts ADR-012 threat model ("backup exposure"). The Migrate confirm dialog now demands a database snapshot instead. - Migration loops batches until each field is exhausted, with failed records excluded from the search domain so one broken record cannot loop forever; previously only the first batch was processed while the summary claimed completion. - Scan reads the classification's stored model_name and tolerates AccessError per model (logged as skipped) — the encryption admin is deliberately not a system admin and cannot read ir.model records. - batch_size validated positive; user-facing strings translated. Tests register a concrete mixin consumer via a vendored Odoo-19 adaptation of odoo-test-helper's FakeModelLoader (the released helper targets pre-19 registry internals); legacy plaintext rows are fabricated with direct SQL since ORM creates auto-encrypt. Module version 19.0.2.0.0 (clears the openspp-modules 19.0.1.0.0 baseline that included the wizard). --- spp_pii_encryption/__init__.py | 1 + spp_pii_encryption/__manifest__.py | 4 +- .../models/field_encryption_config.py | 3 + spp_pii_encryption/readme/DESCRIPTION.md | 5 +- spp_pii_encryption/readme/HISTORY.md | 22 + .../security/ir.model.access.csv | 6 + spp_pii_encryption/tests/__init__.py | 1 + spp_pii_encryption/tests/fake_model_loader.py | 84 ++++ spp_pii_encryption/tests/fake_models.py | 26 ++ .../tests/test_encryption_migration.py | 234 +++++++++++ spp_pii_encryption/wizard/__init__.py | 2 + .../wizard/encryption_migration_views.xml | 149 +++++++ .../wizard/encryption_migration_wizard.py | 389 ++++++++++++++++++ 13 files changed, 923 insertions(+), 3 deletions(-) create mode 100644 spp_pii_encryption/readme/HISTORY.md create mode 100644 spp_pii_encryption/tests/fake_model_loader.py create mode 100644 spp_pii_encryption/tests/fake_models.py create mode 100644 spp_pii_encryption/tests/test_encryption_migration.py create mode 100644 spp_pii_encryption/wizard/__init__.py create mode 100644 spp_pii_encryption/wizard/encryption_migration_views.xml create mode 100644 spp_pii_encryption/wizard/encryption_migration_wizard.py diff --git a/spp_pii_encryption/__init__.py b/spp_pii_encryption/__init__.py index d33610325..0450a0c80 100644 --- a/spp_pii_encryption/__init__.py +++ b/spp_pii_encryption/__init__.py @@ -1,2 +1,3 @@ # Part of OpenSPP. See LICENSE file for full copyright and licensing details. from . import models +from . import wizard diff --git a/spp_pii_encryption/__manifest__.py b/spp_pii_encryption/__manifest__.py index 9135aed82..bf0a50116 100644 --- a/spp_pii_encryption/__manifest__.py +++ b/spp_pii_encryption/__manifest__.py @@ -3,7 +3,7 @@ "name": "OpenSPP PII Encryption", "summary": "Field-level encryption for PII data with searchable blind indexes", "category": "OpenSPP/Configuration", - "version": "19.0.1.0.0", + "version": "19.0.2.0.0", "sequence": 1, "author": "OpenSPP.org", "website": "https://github.com/OpenSPP/OpenSPP2", @@ -14,6 +14,7 @@ "base", "spp_key_management", # Centralized key management "spp_security", + "spp_data_classification", # Classification registry drives the migration scan ], "external_dependencies": { "python": [ @@ -26,6 +27,7 @@ "views/audit_log_views.xml", "views/field_encryption_config_views.xml", "views/menu.xml", + "wizard/encryption_migration_views.xml", ], "assets": { "web.assets_backend": [ diff --git a/spp_pii_encryption/models/field_encryption_config.py b/spp_pii_encryption/models/field_encryption_config.py index a097271cb..023bad0a1 100644 --- a/spp_pii_encryption/models/field_encryption_config.py +++ b/spp_pii_encryption/models/field_encryption_config.py @@ -35,6 +35,9 @@ class FieldEncryptionConfig(models.Model): help="The model containing the field to encrypt", ) model_name = fields.Char( + # Explicit label: the related field would inherit ir.model's "Model" + # string and clash with model_id's label (Odoo warns at every load). + string="Model Name", related="model_id.model", store=True, index=True, diff --git a/spp_pii_encryption/readme/DESCRIPTION.md b/spp_pii_encryption/readme/DESCRIPTION.md index 8c009b8b4..012011c1b 100644 --- a/spp_pii_encryption/readme/DESCRIPTION.md +++ b/spp_pii_encryption/readme/DESCRIPTION.md @@ -24,12 +24,13 @@ After installing: 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. +To encrypt data that existed before encryption was enabled, use the migration wizard at **Key Management > PII Encryption > Data Migration**: scan the classification registry for PII fields on encryption-capable models, preview with a dry run, then migrate in batches. There is deliberately no in-app rollback or plaintext backup — take a database snapshot before migrating. ### UI Location - **Configuration**: Key Management > PII Encryption > Field Configuration - **Audit Log**: Key Management > PII Encryption > Audit Log +- **Data Migration**: Key Management > PII Encryption > Data Migration ### Security @@ -49,4 +50,4 @@ Bulk migration of existing plaintext data (scan, dry-run, backup, rollback) is p ### Dependencies -`base`, `spp_key_management`, `spp_security` +`base`, `spp_key_management`, `spp_security`, `spp_data_classification` diff --git a/spp_pii_encryption/readme/HISTORY.md b/spp_pii_encryption/readme/HISTORY.md new file mode 100644 index 000000000..5744c615e --- /dev/null +++ b/spp_pii_encryption/readme/HISTORY.md @@ -0,0 +1,22 @@ +### 19.0.2.0.0 + +- Re-add the PII data encryption migration wizard (Settings > Key Management > PII Encryption > + Data Migration): scans the classification registry (`spp_data_classification`, new dependency) + for PII fields on encryption-capable models, previews the workload with a dry run, and encrypts + legacy plaintext values in place, batch by batch, with per-record error isolation +- The wizard intentionally ships without the in-app rollback and plaintext backup table it had in + openspp-modules: the rollback never worked (it relied on a `skip_encryption` context no code + implements) and a plaintext backup of the very values being encrypted contradicts ADR-012's + threat model. Take a database snapshot before migrating +- fix: a migration run now processes every batch until each field is exhausted (previously only + the first `batch_size` records were touched while the summary claimed completion) +- fix: scanning a model the operator cannot read is logged and skipped instead of aborting the + whole scan +- fix: give `spp.field.encryption.config`'s `model_name` an explicit "Model Name" label — the + related field inherited ir.model's "Model" string and made Odoo warn about a label clash on + every registry load + +### 19.0.1.0.0 + +- Initial migration to OpenSPP2 (encryption core: encrypted-field mixin, blind-index search, + field configuration, PII access audit log, masked-field widget) diff --git a/spp_pii_encryption/security/ir.model.access.csv b/spp_pii_encryption/security/ir.model.access.csv index dd7cc9424..ccdee8ece 100644 --- a/spp_pii_encryption/security/ir.model.access.csv +++ b/spp_pii_encryption/security/ir.model.access.csv @@ -3,3 +3,9 @@ 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_encryption_migration_wizard_admin,Encryption Migration Wizard Admin,model_spp_encryption_migration_wizard,group_encryption_admin,1,1,1,1 +access_encryption_migration_wizard_system,Encryption Migration Wizard System,model_spp_encryption_migration_wizard,base.group_system,1,1,1,1 +access_encryption_migration_scan_result_admin,Encryption Migration Scan Result Admin,model_spp_encryption_migration_scan_result,group_encryption_admin,1,1,1,1 +access_encryption_migration_scan_result_system,Encryption Migration Scan Result System,model_spp_encryption_migration_scan_result,base.group_system,1,1,1,1 +access_encryption_migration_log_admin,Encryption Migration Log Admin,model_spp_encryption_migration_log,group_encryption_admin,1,1,1,1 +access_encryption_migration_log_system,Encryption Migration Log System,model_spp_encryption_migration_log,base.group_system,1,1,1,1 diff --git a/spp_pii_encryption/tests/__init__.py b/spp_pii_encryption/tests/__init__.py index 1ccefd13b..2114d2332 100644 --- a/spp_pii_encryption/tests/__init__.py +++ b/spp_pii_encryption/tests/__init__.py @@ -1,5 +1,6 @@ # 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_encryption_migration from . import test_field_encryption_config from . import test_audit_log diff --git a/spp_pii_encryption/tests/fake_model_loader.py b/spp_pii_encryption/tests/fake_model_loader.py new file mode 100644 index 000000000..19c114f9a --- /dev/null +++ b/spp_pii_encryption/tests/fake_model_loader.py @@ -0,0 +1,84 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. +"""Minimal fake-model loader for tests, adapted for Odoo 19. + +Registers throwaway model classes into the live registry so tests can +exercise abstract mixins (here: spp.encrypted.field.mixin) on a concrete +model without shipping one in the module. + +This is a scoped adaptation of odoo-test-helper's FakeModelLoader (LGPL, +ACSONE/Camptocamp/Akretion): the released helper (2.1.3) targets +``MetaModel.module_to_models`` and ``Registry.setup_models``, which Odoo 19 +renamed to ``_module_to_models__`` / ``_setup_models__`` (and ``Registry.load`` +now takes a module node instead of a cursor). Only the "add brand-new +models" case is supported — do NOT use this to extend existing models +(that needs the full backup/restore of their ``__bases__``). Replace with +odoo-test-helper once it supports Odoo 19. + +Usage (TransactionCase): + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.loader = FakeModelLoader(cls.env, "spp_pii_encryption") + cls.loader.backup_registry() + from .fake_models import EncryptionTestRecord + cls.loader.update_registry((EncryptionTestRecord,)) + + @classmethod + def tearDownClass(cls): + cls.loader.restore_registry() + super().tearDownClass() +""" + +from types import SimpleNamespace +from unittest import mock + +from odoo import models +from odoo.tools import OrderedSet + + +class FakeModelLoader: + def __init__(self, env, module_name): + self.env = env + self.module_name = module_name + self._module_to_models = models.MetaModel._module_to_models__ + self._known_models = None + self._orig_module_to_models = None + + def backup_registry(self): + self.env.flush_all() + self._known_models = set(self.env.registry.models) + self._orig_module_to_models = {key: list(value) for key, value in self._module_to_models.items()} + + def update_registry(self, odoo_models): + for model in odoo_models: + if any(model._name == known for known in self._known_models): + raise AssertionError(f"{model._name} already exists; this loader only adds new models") + if model not in self._module_to_models[self.module_name]: + self._module_to_models[self.module_name].append(model) + + registry = self.env.registry + # The test cursor must never commit; registry.load/init_models are + # written for the install path. + with mock.patch.object(self.env.cr, "commit"): + model_names = registry.load(SimpleNamespace(name=self.module_name)) + registry._setup_models__(self.env.cr) + new_names = [name for name in model_names if name not in self._known_models] + registry.init_models(self.env.cr, new_names, {"module": self.module_name}) + + def restore_registry(self): + registry = self.env.registry + for name in set(registry.models) - self._known_models: + del registry.models[name] + for key, value in self._orig_module_to_models.items(): + self._module_to_models[key] = list(value) + for key in set(self._module_to_models) - set(self._orig_module_to_models): + del self._module_to_models[key] + # Drop dangling references the fake models left on their parents + # (e.g. the mixin's _inherit_children) + for model_cls in registry.models.values(): + model_cls._inherit_children = OrderedSet( + name for name in model_cls._inherit_children if name in registry.models + ) + with mock.patch.object(self.env.cr, "commit"): + registry._setup_models__(self.env.cr) diff --git a/spp_pii_encryption/tests/fake_models.py b/spp_pii_encryption/tests/fake_models.py new file mode 100644 index 000000000..e6a6038f1 --- /dev/null +++ b/spp_pii_encryption/tests/fake_models.py @@ -0,0 +1,26 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. +"""Throwaway models for the migration-wizard tests. + +Imported ONLY from inside a test's setUpClass (after +FakeModelLoader.backup_registry) — importing this module registers the +classes in MetaModel's module-to-models map, so a module-level import +would leak them into real registry reloads. +""" + +from odoo import fields, models + + +class EncryptionTestRecord(models.Model): + """Concrete consumer of the encrypted-field mixin (none exists in the + real stack yet — the encryption core is a provider layer).""" + + _name = "spp.encryption.test.record" + _description = "Encryption Migration Test Record" + _inherit = ["spp.encrypted.field.mixin"] + + name = fields.Char() + secret = fields.Char() + secret_index = fields.Char(index=True) + + def _get_encrypted_fields(self): + return ["secret"] diff --git a/spp_pii_encryption/tests/test_encryption_migration.py b/spp_pii_encryption/tests/test_encryption_migration.py new file mode 100644 index 000000000..82603c3ea --- /dev/null +++ b/spp_pii_encryption/tests/test_encryption_migration.py @@ -0,0 +1,234 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. +"""Tests for the PII encryption migration wizard.""" + +import base64 +from unittest.mock import patch + +from odoo import Command +from odoo.exceptions import ValidationError +from odoo.tests.common import TransactionCase +from odoo.tools import config + +from .fake_model_loader import FakeModelLoader + +WIZARD_LOGGER = "odoo.addons.spp_pii_encryption.wizard.encryption_migration_wizard" + + +class TestEncryptionMigrationWizard(TransactionCase): + """Wizard behavior against a concrete mixin consumer.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + + # Master key + default provider, as in test_encrypted_field_mixin + cls._original_master_key = config.get("spp_master_key") + config["spp_master_key"] = base64.b64encode(b"M" * 32).decode() + if not cls.env["spp.key.provider.registry"].search([("is_default", "=", True)]): + cls.env["spp.key.provider.registry"].create( + { + "name": "Test Default Provider", + "provider_type": "database", + "is_default": True, + } + ) + + # Register the fake mixin consumer + cls.loader = FakeModelLoader(cls.env, "spp_pii_encryption") + cls.loader.backup_registry() + from .fake_models import EncryptionTestRecord # noqa: PLC0415 — must import after backup_registry + + cls.loader.update_registry((EncryptionTestRecord,)) + cls.TestRecord = cls.env["spp.encryption.test.record"] + + cls.Wizard = cls.env["spp.encryption.migration.wizard"] + cls.Classification = cls.env["spp.field.classification"] + + # Classify the fake model's secret field as PII (what the scan keys on) + cls.secret_classification = cls.Classification.ensure_classification( + "spp.encryption.test.record", + "secret", + "RESTRICTED", + source="manual", + pii_category="direct_id", + ) + cls.test_model = cls.secret_classification.model_id + + @classmethod + def tearDownClass(cls): + cls.loader.restore_registry() + 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 _make_legacy_rows(self, values): + """Create rows holding raw plaintext with no blind index — the state + of data written before the mixin/encryption was enabled. ORM creates + would auto-encrypt, so the plaintext is restored with direct SQL.""" + records = self.TestRecord.create([{"name": f"r{i}", "secret": value} for i, value in enumerate(values)]) + for record, value in zip(records, values, strict=True): + self.env.cr.execute( + "UPDATE spp_encryption_test_record SET secret = %s, secret_index = NULL WHERE id = %s", + (value, record.id), + ) + records.invalidate_recordset() + return records + + def _raw_row(self, record): + """Raw stored (secret, secret_index) straight from SQL.""" + self.env.cr.execute( + "SELECT secret, secret_index FROM spp_encryption_test_record WHERE id = %s", + (record.id,), + ) + return self.env.cr.fetchone() + + def test_batch_size_must_be_positive(self): + with self.assertRaises(ValidationError): + self.Wizard.create({"batch_size": 0}) + + def test_scan_finds_encryptable_pii_field(self): + """Scan reports the mixin-backed field as encryptable and a plain + classified field as not.""" + self._make_legacy_rows(["111", "222"]) + self.Classification.ensure_classification("res.partner", "phone", "CONFIDENTIAL", pii_category="contact") + + wizard = self.Wizard.create({}) + wizard.action_scan() + + self.assertEqual(wizard.state, "ready") + by_model = {r.model_name: r for r in wizard.scan_result_ids} + fake_row = by_model["spp.encryption.test.record"] + self.assertTrue(fake_row.is_encrypted) + self.assertEqual(fake_row.field_name, "secret") + self.assertEqual(fake_row.records_with_data, 2) + self.assertEqual(fake_row.needs_migration, 2) + partner_row = by_model["res.partner"] + self.assertFalse(partner_row.is_encrypted, "res.partner has no phone_index — not encryptable") + + def test_scan_respects_model_filter(self): + self.Classification.ensure_classification("res.partner", "phone", "CONFIDENTIAL", pii_category="contact") + wizard = self.Wizard.create({"model_ids": [Command.set(self.test_model.ids)]}) + wizard.action_scan() + self.assertEqual(wizard.scan_result_ids.mapped("model_id"), self.test_model) + + def test_scan_skips_unreadable_model(self): + """A model the operator cannot read is logged and skipped, not fatal. + The encryption admin is deliberately not a system admin.""" + self.Classification.ensure_classification( + "ir.config_parameter", "value", "RESTRICTED", pii_category="sensitive" + ) + operator = self.env["res.users"].create( + { + "name": "Encryption Operator", + "login": "encryption_operator", + "group_ids": [ + Command.link(self.env.ref("base.group_user").id), + Command.link(self.env.ref("spp_pii_encryption.group_encryption_admin").id), + ], + } + ) + wizard = self.Wizard.with_user(operator).create({}) + wizard.action_scan() + + self.assertEqual(wizard.state, "ready") + skipped = wizard.migration_log_ids.filtered(lambda log: log.status == "skipped") + self.assertIn("ir.config_parameter", skipped.mapped("model_name")) + self.assertNotIn( + "ir.config_parameter", + wizard.scan_result_ids.mapped("model_name"), + "unreadable models must not produce scan results", + ) + self.assertIn("skipped", wizard.result_summary) + + def test_dry_run_counts_without_writing(self): + records = self._make_legacy_rows(["AAA-1", "AAA-2", "AAA-3"]) + wizard = self.Wizard.create({}) + wizard.action_scan() + wizard.action_dry_run() + + self.assertEqual(wizard.state, "done") + self.assertIn("Would process 3", wizard.result_summary) + for record, plain in zip(records, ["AAA-1", "AAA-2", "AAA-3"], strict=True): + stored, index = self._raw_row(record) + self.assertEqual(stored, plain, "dry run must not modify data") + self.assertFalse(index) + dry_logs = wizard.migration_log_ids.filtered(lambda log: log.status == "dry_run") + self.assertEqual(len(dry_logs), 1) + + def test_migrate_encrypts_legacy_plaintext(self): + legacy = self._make_legacy_rows(["PLAIN-1", "PLAIN-2"]) + # An already-encrypted row (ORM create → mixin encrypts) is untouched + encrypted = self.TestRecord.create({"name": "enc", "secret": "ALREADY"}) + _stored_before, index_before = self._raw_row(encrypted) + + wizard = self.Wizard.create({}) + wizard.action_scan() + scan_row = wizard.scan_result_ids.filtered(lambda r: r.model_id == self.test_model) + self.assertEqual(scan_row.needs_migration, 2) + + wizard.action_migrate() + + self.assertEqual(wizard.state, "done") + self.assertIn("Processed 2", wizard.result_summary) + for record, plain in zip(legacy, ["PLAIN-1", "PLAIN-2"], strict=True): + stored, index = self._raw_row(record) + self.assertNotEqual(stored, plain, "stored value must be ciphertext after migration") + self.assertTrue(index, "blind index must be computed") + # Decryption happens in read() (attribute access returns the + # raw stored value by design) + self.assertEqual(record.read(["secret"])[0]["secret"], plain) + stored_after, index_after = self._raw_row(encrypted) + self.assertEqual(index_after, index_before, "already-encrypted rows must not be touched") + + def test_migrate_loops_all_batches(self): + """One click processes every batch, not just the first.""" + legacy = self._make_legacy_rows(["B-1", "B-2", "B-3"]) + wizard = self.Wizard.create({"batch_size": 1}) + wizard.action_scan() + wizard.action_migrate() + + self.assertEqual(wizard.state, "done") + self.assertIn("Processed 3", wizard.result_summary) + for record in legacy: + _stored, index = self._raw_row(record) + self.assertTrue(index) + + def test_migrate_isolates_failing_record(self): + """A record whose write blows up is excluded and reported; the rest + of the field still migrates and the batch loop terminates.""" + legacy = self._make_legacy_rows(["F-1", "F-2", "F-3"]) + bad = legacy[1] + TestRecordClass = self.env.registry["spp.encryption.test.record"] + original_write = TestRecordClass.write + + def failing_write(record_self, vals): + if bad.id in record_self.ids: + raise ValueError("simulated storage failure") + return original_write(record_self, vals) + + wizard = self.Wizard.create({"batch_size": 1}) + wizard.action_scan() + with patch.object(TestRecordClass, "write", failing_write): + with self.assertLogs(WIZARD_LOGGER, level="ERROR") as capture: + wizard.action_migrate() + self.assertIn("Error migrating record", capture.output[0]) + + self.assertEqual(wizard.state, "error") + self.assertIn("Processed 2", wizard.result_summary) + self.assertIn("1 record(s) failed", wizard.result_summary) + _stored, bad_index = self._raw_row(bad) + self.assertFalse(bad_index, "failed record stays unmigrated") + for record in (legacy[0], legacy[2]): + _stored, index = self._raw_row(record) + self.assertTrue(index) + error_logs = wizard.migration_log_ids.filtered(lambda log: log.status == "error") + self.assertTrue(error_logs) + + def test_nothing_to_migrate(self): + wizard = self.Wizard.create({}) + wizard.action_scan() + wizard.action_migrate() + self.assertEqual(wizard.state, "done") + self.assertEqual(wizard.result_summary, "No fields need migration.") diff --git a/spp_pii_encryption/wizard/__init__.py b/spp_pii_encryption/wizard/__init__.py new file mode 100644 index 000000000..2989a93dc --- /dev/null +++ b/spp_pii_encryption/wizard/__init__.py @@ -0,0 +1,2 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. +from . import encryption_migration_wizard diff --git a/spp_pii_encryption/wizard/encryption_migration_views.xml b/spp_pii_encryption/wizard/encryption_migration_views.xml new file mode 100644 index 000000000..efd6eab32 --- /dev/null +++ b/spp_pii_encryption/wizard/encryption_migration_views.xml @@ -0,0 +1,149 @@ + + + + + spp.encryption.migration.wizard.view.form + spp.encryption.migration.wizard + +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ + + + Encrypt PII Data + spp.encryption.migration.wizard + form + new + + + + +
diff --git a/spp_pii_encryption/wizard/encryption_migration_wizard.py b/spp_pii_encryption/wizard/encryption_migration_wizard.py new file mode 100644 index 000000000..6960bb045 --- /dev/null +++ b/spp_pii_encryption/wizard/encryption_migration_wizard.py @@ -0,0 +1,389 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. +"""PII Data Encryption Migration Wizard. + +Migrates existing plaintext PII data to encrypted format. This is a +one-time migration driven by the classification registry: every field +classified as PII (spp.field.classification, is_pii=True) on a model that +supports encryption (inherits spp.encrypted.field.mixin, i.e. has a +``_index`` blind-index column) can be scanned, previewed and +encrypted in place. + +Features: +- Scan classified models for PII fields that need encryption +- Dry-run mode to preview how many records a migration would touch +- Batched in-place migration with per-record error isolation + +There is deliberately NO in-app rollback or plaintext backup: encrypted +values remain readable through the mixin, and a plaintext backup table +would defeat the purpose of the migration (ADR-012 threat model, "backup +exposure"). Operators must take a database snapshot before migrating. +""" + +import logging + +from odoo import _, api, fields, models +from odoo.exceptions import AccessError, ValidationError + +_logger = logging.getLogger(__name__) + + +class EncryptionMigrationWizard(models.TransientModel): + """Wizard to migrate existing plaintext data to encrypted format.""" + + _name = "spp.encryption.migration.wizard" + _description = "PII Data Encryption Migration Wizard" + + # Model selection + model_ids = fields.Many2many( + comodel_name="ir.model", + string="Models to Process", + help="Select models to process. Leave empty to process all models with PII fields.", + ) + + # Processing options + batch_size = fields.Integer( + default=100, + help="Number of records to process per batch", + ) + + # Results + state = fields.Selection( + selection=[ + ("draft", "Not Started"), + ("scanning", "Scanning"), + ("ready", "Ready"), + ("processing", "Processing"), + ("done", "Done"), + ("error", "Error"), + ], + default="draft", + ) + + progress = fields.Float(default=0.0) + + result_summary = fields.Text(readonly=True) + + scan_result_ids = fields.One2many( + comodel_name="spp.encryption.migration.scan.result", + inverse_name="wizard_id", + string="Scan Results", + ) + + migration_log_ids = fields.One2many( + comodel_name="spp.encryption.migration.log", + inverse_name="wizard_id", + string="Migration Log", + ) + + @api.constrains("batch_size") + def _check_batch_size(self): + for wizard in self: + if wizard.batch_size <= 0: + raise ValidationError(_("Batch size must be a positive number.")) + + def action_scan(self): + """Scan for PII fields that need encryption.""" + self.ensure_one() + self.state = "scanning" + self.scan_result_ids.unlink() + self.migration_log_ids.unlink() + + # Get PII field classifications + domain = [("is_pii", "=", True)] + if self.model_ids: + domain.append(("model_id", "in", self.model_ids.ids)) + classifications = self.env["spp.field.classification"].search(domain) + + results = [] + skipped_models = set() + for classification in classifications: + # Stored related on the classification — deliberately NOT + # classification.model_id.model: ir.model records are readable + # only by the Access Rights group, which the encryption admin + # does not necessarily hold. + model_name = classification.model_name + field_name = classification.field_name + + # Check if model exists in the registry + Model = self.env.get(model_name) + if Model is None: + continue + + # Check if field exists + if field_name not in Model._fields: + continue + + # A field is encryptable when its model carries the mixin's + # blind-index companion column + index_field = f"{field_name}_index" + is_encrypted = index_field in Model._fields + + try: + total_records = Model.search_count([]) + records_with_data = Model.search_count( + [ + (field_name, "!=", False), + (field_name, "!=", ""), + ] + ) + encrypted_count = 0 + if is_encrypted: + encrypted_count = Model.search_count([(index_field, "!=", False)]) + except AccessError: + # The encryption admin is deliberately not a system admin; + # one unreadable model must not abort the whole scan. + if model_name not in skipped_models: + skipped_models.add(model_name) + self._log_entry( + model_name, + field_name, + "skipped", + _("Access denied — run the scan as a user who can read this model."), + ) + continue + + results.append( + { + "wizard_id": self.id, + "model_id": classification.model_id.id, + "model_name": model_name, + "field_name": field_name, + "classification_id": classification.id, + "total_records": total_records, + "records_with_data": records_with_data, + "encrypted_records": encrypted_count, + "needs_migration": max(0, records_with_data - encrypted_count), + "is_encrypted": is_encrypted, + } + ) + + self.env["spp.encryption.migration.scan.result"].create(results) + + self.state = "ready" + model_count = len({r["model_id"] for r in results}) + summary = _("Scan complete. Found %(fields)d PII fields across %(models)d models.") % { + "fields": len(results), + "models": model_count, + } + if skipped_models: + summary += "\n" + _("%(count)d model(s) skipped (no read access) — see the Migration Log.") % { + "count": len(skipped_models) + } + self.result_summary = summary + + return self._return_wizard() + + def action_dry_run(self): + """Preview what the migration will do.""" + self.ensure_one() + return self._run_migration(dry_run=True) + + def action_migrate(self): + """Run the actual migration.""" + self.ensure_one() + return self._run_migration(dry_run=False) + + def _run_migration(self, dry_run=False): + """Execute the migration process. + + Args: + dry_run: If True, only count what the migration would touch + """ + self.state = "processing" + self.progress = 0.0 + self.migration_log_ids.unlink() + + # Only fields with encryption support and pending records + to_migrate = self.scan_result_ids.filtered(lambda r: r.needs_migration > 0 and r.is_encrypted) + + if not to_migrate: + self.result_summary = _("No fields need migration.") + self.state = "done" + return self._return_wizard() + + total_records = sum(to_migrate.mapped("needs_migration")) + processed = 0 + errors = [] + + for scan_result in to_migrate: + model_name = scan_result.model_name + field_name = scan_result.field_name + try: + count, failed = self._migrate_field(scan_result, dry_run) + except Exception as e: + # A field-level failure (bad key setup, missing column, ...) + # must not abort the remaining fields. + errors.append(f"{model_name}.{field_name}: {e}") + self._log_entry(model_name, field_name, "error", str(e)) + _logger.exception("Migration error for %s.%s", model_name, field_name) + continue + + processed += count + status = "dry_run" if dry_run else "success" + action_label = _("Would process %(count)d records") if dry_run else _("Processed %(count)d records") + self._log_entry(model_name, field_name, status, action_label % {"count": count}) + if failed: + errors.append( + _("%(model)s.%(field)s: %(count)d record(s) failed — see the server log.") + % {"model": model_name, "field": field_name, "count": failed} + ) + self._log_entry( + model_name, + field_name, + "error", + _("%(count)d record(s) failed — see the server log.") % {"count": failed}, + ) + + self.progress = (processed / total_records) * 100 if total_records else 100 + + if dry_run: + self.result_summary = _("Dry run complete. Would process %(count)d records.") % {"count": processed} + else: + self.result_summary = _("Migration complete. Processed %(count)d records.") % {"count": processed} + + if errors: + self.result_summary += "\n\n" + _("Errors (%(count)d):", count=len(errors)) + "\n" + "\n".join(errors) + self.state = "error" + else: + self.state = "done" + + return self._return_wizard() + + def _pending_domain(self, field_name): + """Domain matching records that hold data but no blind index yet.""" + index_field = f"{field_name}_index" + return [ + (field_name, "!=", False), + (field_name, "!=", ""), + "|", + (index_field, "=", False), + (index_field, "=", ""), + ] + + def _migrate_field(self, scan_result, dry_run=False): + """Migrate a single field, batch by batch, until exhausted. + + Args: + scan_result: The scan result to migrate + dry_run: If True, only count matching records + + Returns: + tuple: (records processed, records that failed) + """ + model_name = scan_result.model_name + field_name = scan_result.field_name + Model = self.env[model_name] + base_domain = self._pending_domain(field_name) + + if dry_run: + # No writes happen in a dry run, so looping would never + # converge — a count IS the preview. + return Model.search_count(base_domain), 0 + + processed = 0 + failed_ids = [] + while True: + # Exclude records that already failed, otherwise a persistently + # failing record would match forever and loop this batch. + domain = base_domain if not failed_ids else [("id", "not in", failed_ids), *base_domain] + records = Model.search(domain, limit=self.batch_size) + if not records: + break + + for record in records: + try: + # Attribute access returns the raw stored value (the + # mixin decrypts only in read()); these records hold + # legacy plaintext, which is exactly what we re-write. + value = record[field_name] + if not value: + failed_ids.append(record.id) + continue + # The write-back goes through the encrypted-field + # mixin, which encrypts the value and computes the + # blind index. + record.write({field_name: value}) + processed += 1 + except Exception: + failed_ids.append(record.id) + # Log ids only — never field values (PII). + _logger.exception("Error migrating record %s#%s", model_name, record.id) + + # Bound memory across large tables. + self.env.flush_all() + self.env.invalidate_all() + + return processed, len(failed_ids) + + def _log_entry(self, model_name, field_name, status, message): + """Create a migration-log line.""" + self.env["spp.encryption.migration.log"].create( + { + "wizard_id": self.id, + "model_name": model_name, + "field_name": field_name, + "status": status, + "message": message, + "timestamp": fields.Datetime.now(), + } + ) + + def _return_wizard(self): + """Return the wizard view.""" + return { + "type": "ir.actions.act_window", + "res_model": self._name, + "res_id": self.id, + "view_mode": "form", + "target": "new", + } + + +class EncryptionMigrationScanResult(models.TransientModel): + """Scan result for encryption migration.""" + + _name = "spp.encryption.migration.scan.result" + _description = "Encryption Migration Scan Result" + + wizard_id = fields.Many2one( + comodel_name="spp.encryption.migration.wizard", + ondelete="cascade", + ) + model_id = fields.Many2one( + comodel_name="ir.model", + string="Model", + ) + # Plain copy of the technical name: readable by operators who cannot + # read ir.model records + model_name = fields.Char() + field_name = fields.Char(string="Field") + classification_id = fields.Many2one(comodel_name="spp.field.classification") + total_records = fields.Integer() + records_with_data = fields.Integer() + encrypted_records = fields.Integer(string="Already Encrypted") + needs_migration = fields.Integer() + is_encrypted = fields.Boolean(string="Has Encryption Support") + + +class EncryptionMigrationLog(models.TransientModel): + """Log entry for encryption migration.""" + + _name = "spp.encryption.migration.log" + _description = "Encryption Migration Log" + + wizard_id = fields.Many2one( + comodel_name="spp.encryption.migration.wizard", + ondelete="cascade", + ) + model_name = fields.Char(string="Model") + field_name = fields.Char(string="Field") + status = fields.Selection( + selection=[ + ("success", "Success"), + ("dry_run", "Dry Run"), + ("error", "Error"), + ("skipped", "Skipped"), + ], + ) + message = fields.Text() + timestamp = fields.Datetime() From 33b4e3b8d015fd9705023c14fc017bf7d6f16e5b Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Tue, 25 Aug 2026 13:29:53 +0800 Subject: [PATCH 2/7] feat(spp_registry_encryption): mask registrant ID numbers with audited reveal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New applier module wiring the masked_char widget (spp_pii_encryption) into the individual and group Identity tabs: spp.registry.id values render as ••••-••••-1234, and revealing them requires spp_data_classification.group_pii_full_access_admin — the PII access group the RESTRICTED classification level points at — with every reveal written to the PII access audit log. Display masking only: encrypting the stored values (mixin on spp.registry.id) is a separate change with search/dedup impact. spp_registry itself stays free of upward dependencies. --- spp_registry_encryption/__init__.py | 1 + spp_registry_encryption/__manifest__.py | 31 ++++++++++++++ spp_registry_encryption/pyproject.toml | 3 ++ spp_registry_encryption/readme/DESCRIPTION.md | 24 +++++++++++ .../static/description/icon.png | Bin 0 -> 15480 bytes spp_registry_encryption/tests/__init__.py | 2 + .../tests/test_view_wiring.py | 39 ++++++++++++++++++ .../views/registry_id_views.xml | 36 ++++++++++++++++ 8 files changed, 136 insertions(+) create mode 100644 spp_registry_encryption/__init__.py create mode 100644 spp_registry_encryption/__manifest__.py create mode 100644 spp_registry_encryption/pyproject.toml create mode 100644 spp_registry_encryption/readme/DESCRIPTION.md create mode 100644 spp_registry_encryption/static/description/icon.png create mode 100644 spp_registry_encryption/tests/__init__.py create mode 100644 spp_registry_encryption/tests/test_view_wiring.py create mode 100644 spp_registry_encryption/views/registry_id_views.xml diff --git a/spp_registry_encryption/__init__.py b/spp_registry_encryption/__init__.py new file mode 100644 index 000000000..441611e10 --- /dev/null +++ b/spp_registry_encryption/__init__.py @@ -0,0 +1 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. diff --git a/spp_registry_encryption/__manifest__.py b/spp_registry_encryption/__manifest__.py new file mode 100644 index 000000000..57190dbb1 --- /dev/null +++ b/spp_registry_encryption/__manifest__.py @@ -0,0 +1,31 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. +# pylint: disable=pointless-statement +{ + "name": "OpenSPP Registry PII Display", + "summary": "Mask registrant ID numbers with audited reveal", + "category": "OpenSPP/Core", + "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": [ + "spp_registry", + "spp_pii_encryption", # masked_char widget + reveal audit log + "spp_data_classification", # PII access groups gating the reveal + ], + "external_dependencies": { + "python": [], + }, + "data": [ + "views/registry_id_views.xml", + ], + "assets": {}, + "demo": [], + "images": [], + "application": False, + "installable": True, + "auto_install": False, +} diff --git a/spp_registry_encryption/pyproject.toml b/spp_registry_encryption/pyproject.toml new file mode 100644 index 000000000..4231d0ccc --- /dev/null +++ b/spp_registry_encryption/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/spp_registry_encryption/readme/DESCRIPTION.md b/spp_registry_encryption/readme/DESCRIPTION.md new file mode 100644 index 000000000..3cb04c37f --- /dev/null +++ b/spp_registry_encryption/readme/DESCRIPTION.md @@ -0,0 +1,24 @@ +Applies OpenSPP's PII display protections to the registry: registrant ID numbers (national IDs, passports, and other `spp.registry.id` values) render masked in the Identity tabs of the individual and group forms, with an audited reveal for authorized users. + +### What it does + +- ID numbers display as `••••-••••-1234` (mask pattern `****-****-####`) instead of plaintext +- Clicking reveal shows the value only for members of `spp_data_classification.group_pii_full_access_admin` — the PII access group the RESTRICTED classification level points at +- Every reveal is recorded in the PII access audit log (`spp.pii.audit.log`, action `reveal`) + +### What it does NOT do + +Display masking only. The stored values are not encrypted by this module — applying `spp.encrypted.field.mixin` to `spp.registry.id` (blind-index search, encrypted storage) is a separate change with search/deduplication impact. + +### UI Location + +- Individual form > Identity tab > Identity Documents +- Group form > Identity tab > Identity Documents + +### Security + +No models and no ACLs of its own. Reveal authorization uses `spp_data_classification.group_pii_full_access_admin`; grant that group to the officers who legitimately need to read full ID numbers. + +### Dependencies + +`spp_registry`, `spp_pii_encryption`, `spp_data_classification` diff --git a/spp_registry_encryption/static/description/icon.png b/spp_registry_encryption/static/description/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..c7dbdaaf1dace8f0ccf8c2087047ddfcf584af0c GIT binary patch literal 15480 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 + + + + spp_registry_encryption.view_individuals_form_masked_ids + res.partner + + + + masked_char + ****-****-#### + spp_data_classification.group_pii_full_access_admin + + + masked_char + ****-****-#### + spp_data_classification.group_pii_full_access_admin + + + + From 0003156285f0d7564ae7ccd828b7903892c08333 Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Tue, 25 Aug 2026 14:10:15 +0800 Subject: [PATCH 3/7] docs: generate READMEs for spp_pii_encryption and spp_registry_encryption spp_pii_encryption files applied verbatim from the CI pre-commit run (32815132448). spp_registry_encryption files bootstrapped via the pinned oca-gen hook in a pre-commit-managed env (CI cannot print a diff for files that do not exist yet); if CI renders them differently, its next printed diff is authoritative. --- spp_pii_encryption/README.rst | 46 +- .../static/description/index.html | 61 ++- spp_registry_encryption/README.rst | 110 +++++ .../static/description/index.html | 455 ++++++++++++++++++ 4 files changed, 655 insertions(+), 17 deletions(-) create mode 100644 spp_registry_encryption/README.rst create mode 100644 spp_registry_encryption/static/description/index.html diff --git a/spp_pii_encryption/README.rst b/spp_pii_encryption/README.rst index 7d0b39a0b..5c41fcc76 100644 --- a/spp_pii_encryption/README.rst +++ b/spp_pii_encryption/README.rst @@ -68,9 +68,12 @@ After installing: (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. +To encrypt data that existed before encryption was enabled, use the +migration wizard at **Key Management > PII Encryption > Data +Migration**: scan the classification registry for PII fields on +encryption-capable models, preview with a dry run, then migrate in +batches. There is deliberately no in-app rollback or plaintext backup — +take a database snapshot before migrating. UI Location ~~~~~~~~~~~ @@ -78,6 +81,7 @@ UI Location - **Configuration**: Key Management > PII Encryption > Field Configuration - **Audit Log**: Key Management > PII Encryption > Audit Log +- **Data Migration**: Key Management > PII Encryption > Data Migration Security ~~~~~~~~ @@ -114,7 +118,8 @@ Extension Points Dependencies ~~~~~~~~~~~~ -``base``, ``spp_key_management``, ``spp_security`` +``base``, ``spp_key_management``, ``spp_security``, +``spp_data_classification`` .. IMPORTANT:: This is an alpha version, the data model and design can change at any time without warning. @@ -125,6 +130,39 @@ Dependencies .. contents:: :local: +Changelog +========= + +19.0.2.0.0 +~~~~~~~~~~ + +- Re-add the PII data encryption migration wizard (Settings > Key + Management > PII Encryption > Data Migration): scans the + classification registry (``spp_data_classification``, new dependency) + for PII fields on encryption-capable models, previews the workload + with a dry run, and encrypts legacy plaintext values in place, batch + by batch, with per-record error isolation +- The wizard intentionally ships without the in-app rollback and + plaintext backup table it had in openspp-modules: the rollback never + worked (it relied on a ``skip_encryption`` context no code implements) + and a plaintext backup of the very values being encrypted contradicts + ADR-012's threat model. Take a database snapshot before migrating +- fix: a migration run now processes every batch until each field is + exhausted (previously only the first ``batch_size`` records were + touched while the summary claimed completion) +- fix: scanning a model the operator cannot read is logged and skipped + instead of aborting the whole scan +- fix: give ``spp.field.encryption.config``'s ``model_name`` an explicit + "Model Name" label — the related field inherited ir.model's "Model" + string and made Odoo warn about a label clash on every registry load + +19.0.1.0.0 +~~~~~~~~~~ + +- Initial migration to OpenSPP2 (encryption core: encrypted-field mixin, + blind-index search, field configuration, PII access audit log, + masked-field widget) + Bug Tracker =========== diff --git a/spp_pii_encryption/static/description/index.html b/spp_pii_encryption/static/description/index.html index cc58ec698..825f76f74 100644 --- a/spp_pii_encryption/static/description/index.html +++ b/spp_pii_encryption/static/description/index.html @@ -427,9 +427,12 @@

Configuration

(last 4 characters), or Phonetic (Soundex for names)
  • 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.

    +

    To encrypt data that existed before encryption was enabled, use the +migration wizard at Key Management > PII Encryption > Data +Migration: scan the classification registry for PII fields on +encryption-capable models, preview with a dry run, then migrate in +batches. There is deliberately no in-app rollback or plaintext backup — +take a database snapshot before migrating.

    UI Location

    @@ -437,6 +440,7 @@

    UI Location

  • Configuration: Key Management > PII Encryption > Field Configuration
  • Audit Log: Key Management > PII Encryption > Audit Log
  • +
  • Data Migration: Key Management > PII Encryption > Data Migration
  • @@ -485,7 +489,8 @@

    Extension Points

    Dependencies

    -

    base, spp_key_management, spp_security

    +

    base, spp_key_management, spp_security, +spp_data_classification

    Important

    This is an alpha version, the data model and design can change at any time without warning. @@ -494,16 +499,46 @@

    Dependencies

    Table of contents

    + +
    +
    +

    19.0.2.0.0

    +
      +
    • Re-add the PII data encryption migration wizard (Settings > Key +Management > PII Encryption > Data Migration): scans the +classification registry (spp_data_classification, new dependency) +for PII fields on encryption-capable models, previews the workload +with a dry run, and encrypts legacy plaintext values in place, batch +by batch, with per-record error isolation
    • +
    • The wizard intentionally ships without the in-app rollback and +plaintext backup table it had in openspp-modules: the rollback never +worked (it relied on a skip_encryption context no code implements) +and a plaintext backup of the very values being encrypted contradicts +ADR-012’s threat model. Take a database snapshot before migrating
    • +
    • fix: a migration run now processes every batch until each field is +exhausted (previously only the first batch_size records were +touched while the summary claimed completion)
    • +
    • fix: scanning a model the operator cannot read is logged and skipped +instead of aborting the whole scan
    • +
    • fix: give spp.field.encryption.config’s model_name an explicit +“Model Name” label — the related field inherited ir.model’s “Model” +string and made Odoo warn about a label clash on every registry load
    +
    +

    19.0.1.0.0

    +
      +
    • Initial migration to OpenSPP2 (encryption core: encrypted-field mixin, +blind-index search, field configuration, PII access audit log, +masked-field widget)
    • +
    -

    Bug Tracker

    +

    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 @@ -511,15 +546,15 @@

    Bug Tracker

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

    -

    Credits

    +

    Credits

    -

    Authors

    +

    Authors

    • OpenSPP.org
    -

    Maintainers

    +

    Maintainers

    Current maintainers:

    jeremi gonzalesedwin1123

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

    diff --git a/spp_registry_encryption/README.rst b/spp_registry_encryption/README.rst new file mode 100644 index 000000000..bd864fa7c --- /dev/null +++ b/spp_registry_encryption/README.rst @@ -0,0 +1,110 @@ +============================ +OpenSPP Registry PII Display +============================ + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:4131b9e92df89de6365ed994e6a69506d557e2215da2a8cf07b9d69911b46ff4 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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_registry_encryption + :alt: OpenSPP/OpenSPP2 + +|badge1| |badge2| |badge3| + +Applies OpenSPP's PII display protections to the registry: registrant ID +numbers (national IDs, passports, and other ``spp.registry.id`` values) +render masked in the Identity tabs of the individual and group forms, +with an audited reveal for authorized users. + +What it does +~~~~~~~~~~~~ + +- ID numbers display as ``••••-••••-1234`` (mask pattern + ``****-****-####``) instead of plaintext +- Clicking reveal shows the value only for members of + ``spp_data_classification.group_pii_full_access_admin`` — the PII + access group the RESTRICTED classification level points at +- Every reveal is recorded in the PII access audit log + (``spp.pii.audit.log``, action ``reveal``) + +What it does NOT do +~~~~~~~~~~~~~~~~~~~ + +Display masking only. The stored values are not encrypted by this module +— applying ``spp.encrypted.field.mixin`` to ``spp.registry.id`` +(blind-index search, encrypted storage) is a separate change with +search/deduplication impact. + +UI Location +~~~~~~~~~~~ + +- Individual form > Identity tab > Identity Documents +- Group form > Identity tab > Identity Documents + +Security +~~~~~~~~ + +No models and no ACLs of its own. Reveal authorization uses +``spp_data_classification.group_pii_full_access_admin``; grant that +group to the officers who legitimately need to read full ID numbers. + +Dependencies +~~~~~~~~~~~~ + +``spp_registry``, ``spp_pii_encryption``, ``spp_data_classification`` + +.. 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_registry_encryption/static/description/index.html b/spp_registry_encryption/static/description/index.html new file mode 100644 index 000000000..70731f579 --- /dev/null +++ b/spp_registry_encryption/static/description/index.html @@ -0,0 +1,455 @@ + + + + + +OpenSPP Registry PII Display + + + +
    +

    OpenSPP Registry PII Display

    + + +

    Alpha License: LGPL-3 OpenSPP/OpenSPP2

    +

    Applies OpenSPP’s PII display protections to the registry: registrant ID +numbers (national IDs, passports, and other spp.registry.id values) +render masked in the Identity tabs of the individual and group forms, +with an audited reveal for authorized users.

    +
    +

    What it does

    +
      +
    • ID numbers display as ••••-••••-1234 (mask pattern +****-****-####) instead of plaintext
    • +
    • Clicking reveal shows the value only for members of +spp_data_classification.group_pii_full_access_admin — the PII +access group the RESTRICTED classification level points at
    • +
    • Every reveal is recorded in the PII access audit log +(spp.pii.audit.log, action reveal)
    • +
    +
    +
    +

    What it does NOT do

    +

    Display masking only. The stored values are not encrypted by this module +— applying spp.encrypted.field.mixin to spp.registry.id +(blind-index search, encrypted storage) is a separate change with +search/deduplication impact.

    +
    +
    +

    UI Location

    +
      +
    • Individual form > Identity tab > Identity Documents
    • +
    • Group form > Identity tab > Identity Documents
    • +
    +
    +
    +

    Security

    +

    No models and no ACLs of its own. Reveal authorization uses +spp_data_classification.group_pii_full_access_admin; grant that +group to the officers who legitimately need to read full ID numbers.

    +
    +
    +

    Dependencies

    +

    spp_registry, spp_pii_encryption, spp_data_classification

    +
    +

    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.

    +
    +
    +
    +
    + + From 3b9065d8f0a905dca22bad5d83cb3a141bfa3b2f Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Tue, 25 Aug 2026 14:38:26 +0800 Subject: [PATCH 4/7] docs(spp_registry_encryption): frame the masking as display de-emphasis MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per the widget-honesty findings in the spp_pii_encryption hardening tracker (#451 items 6-7): the widget masks readonly display and audits reveals through its control, but entering the editable cell shows the value and the plaintext reaches the browser via the normal record read either way. The description now says exactly that — the access boundary remains record ACLs; the mask buys shoulder-surfing protection and an audit trail for deliberate reveals. Mask keeps the last-4 pattern the classification registry itself seeds for national IDs; a platform-wide default-mask change belongs to #451 item 7. --- spp_registry_encryption/readme/DESCRIPTION.md | 23 ++++++++++++++----- .../views/registry_id_views.xml | 10 ++++---- 2 files changed, 23 insertions(+), 10 deletions(-) diff --git a/spp_registry_encryption/readme/DESCRIPTION.md b/spp_registry_encryption/readme/DESCRIPTION.md index 3cb04c37f..f6dc806eb 100644 --- a/spp_registry_encryption/readme/DESCRIPTION.md +++ b/spp_registry_encryption/readme/DESCRIPTION.md @@ -1,14 +1,25 @@ -Applies OpenSPP's PII display protections to the registry: registrant ID numbers (national IDs, passports, and other `spp.registry.id` values) render masked in the Identity tabs of the individual and group forms, with an audited reveal for authorized users. +Applies OpenSPP's PII display de-emphasis to the registry: registrant ID numbers (national IDs, passports, and other `spp.registry.id` values) render masked in the Identity tabs of the individual and group forms, with an audited reveal control. ### What it does -- ID numbers display as `••••-••••-1234` (mask pattern `****-****-####`) instead of plaintext -- Clicking reveal shows the value only for members of `spp_data_classification.group_pii_full_access_admin` — the PII access group the RESTRICTED classification level points at -- Every reveal is recorded in the PII access audit log (`spp.pii.audit.log`, action `reveal`) +- ID numbers display as `••••-••••-1234` (mask pattern `****-****-####`, matching the mask the classification registry seeds for national IDs) instead of plaintext +- The reveal control checks membership in `spp_data_classification.group_pii_full_access_admin` — the PII access group the RESTRICTED classification level points at +- Every reveal through the control is recorded in the PII access audit log (`spp.pii.audit.log`, action `reveal`) + +### What it is — and is not + +This is **display de-emphasis, not an access control** (see the "widget honesty" findings in the `spp_pii_encryption` hardening tracker): + +- The masking applies to readonly display. Entering the editable list cell shows the value in the input, without a group check or an audit entry. +- The value is delivered to the browser by the normal record read regardless of mask state; the widget controls presentation, not data access. +- The real access boundary remains the record's ACLs and record rules: whoever can read/write `spp.registry.id` can obtain the value. +- What the mask does buy: protection against shoulder-surfing and casual over-exposure in day-to-day screens, plus an audit trail for deliberate reveals through the control. + +Server-side field-level enforcement is tracked in the `spp_pii_encryption` hardening issue; when it lands, this module is where the registry adopts it. ### What it does NOT do -Display masking only. The stored values are not encrypted by this module — applying `spp.encrypted.field.mixin` to `spp.registry.id` (blind-index search, encrypted storage) is a separate change with search/deduplication impact. +The stored values are not encrypted by this module — applying `spp.encrypted.field.mixin` to `spp.registry.id` (blind-index search, encrypted storage) is a separate change with search/deduplication impact. ### UI Location @@ -17,7 +28,7 @@ Display masking only. The stored values are not encrypted by this module — app ### Security -No models and no ACLs of its own. Reveal authorization uses `spp_data_classification.group_pii_full_access_admin`; grant that group to the officers who legitimately need to read full ID numbers. +No models and no ACLs of its own. The reveal control's group is `spp_data_classification.group_pii_full_access_admin`; grant it to officers who legitimately need to read full ID numbers. Note the mask shows the last 4 characters to anyone who can read the record. ### Dependencies diff --git a/spp_registry_encryption/views/registry_id_views.xml b/spp_registry_encryption/views/registry_id_views.xml index 97abc500a..17af3ffad 100644 --- a/spp_registry_encryption/views/registry_id_views.xml +++ b/spp_registry_encryption/views/registry_id_views.xml @@ -1,9 +1,11 @@ - + Date: Tue, 25 Aug 2026 14:48:20 +0800 Subject: [PATCH 5/7] docs(spp_registry_encryption): regenerate README from updated fragments Applied verbatim from CI pre-commit run 32817863234. --- spp_registry_encryption/README.rst | 48 ++++++++++++++----- .../static/description/index.html | 47 +++++++++++++----- 2 files changed, 71 insertions(+), 24 deletions(-) diff --git a/spp_registry_encryption/README.rst b/spp_registry_encryption/README.rst index bd864fa7c..6d7535c11 100644 --- a/spp_registry_encryption/README.rst +++ b/spp_registry_encryption/README.rst @@ -22,28 +22,51 @@ OpenSPP Registry PII Display |badge1| |badge2| |badge3| -Applies OpenSPP's PII display protections to the registry: registrant ID +Applies OpenSPP's PII display de-emphasis to the registry: registrant ID numbers (national IDs, passports, and other ``spp.registry.id`` values) render masked in the Identity tabs of the individual and group forms, -with an audited reveal for authorized users. +with an audited reveal control. What it does ~~~~~~~~~~~~ - ID numbers display as ``••••-••••-1234`` (mask pattern - ``****-****-####``) instead of plaintext -- Clicking reveal shows the value only for members of + ``****-****-####``, matching the mask the classification registry + seeds for national IDs) instead of plaintext +- The reveal control checks membership in ``spp_data_classification.group_pii_full_access_admin`` — the PII access group the RESTRICTED classification level points at -- Every reveal is recorded in the PII access audit log - (``spp.pii.audit.log``, action ``reveal``) +- Every reveal through the control is recorded in the PII access audit + log (``spp.pii.audit.log``, action ``reveal``) + +What it is — and is not +~~~~~~~~~~~~~~~~~~~~~~~ + +This is **display de-emphasis, not an access control** (see the "widget +honesty" findings in the ``spp_pii_encryption`` hardening tracker): + +- The masking applies to readonly display. Entering the editable list + cell shows the value in the input, without a group check or an audit + entry. +- The value is delivered to the browser by the normal record read + regardless of mask state; the widget controls presentation, not data + access. +- The real access boundary remains the record's ACLs and record rules: + whoever can read/write ``spp.registry.id`` can obtain the value. +- What the mask does buy: protection against shoulder-surfing and casual + over-exposure in day-to-day screens, plus an audit trail for + deliberate reveals through the control. + +Server-side field-level enforcement is tracked in the +``spp_pii_encryption`` hardening issue; when it lands, this module is +where the registry adopts it. What it does NOT do ~~~~~~~~~~~~~~~~~~~ -Display masking only. The stored values are not encrypted by this module -— applying ``spp.encrypted.field.mixin`` to ``spp.registry.id`` -(blind-index search, encrypted storage) is a separate change with +The stored values are not encrypted by this module — applying +``spp.encrypted.field.mixin`` to ``spp.registry.id`` (blind-index +search, encrypted storage) is a separate change with search/deduplication impact. UI Location @@ -55,9 +78,10 @@ UI Location Security ~~~~~~~~ -No models and no ACLs of its own. Reveal authorization uses -``spp_data_classification.group_pii_full_access_admin``; grant that -group to the officers who legitimately need to read full ID numbers. +No models and no ACLs of its own. The reveal control's group is +``spp_data_classification.group_pii_full_access_admin``; grant it to +officers who legitimately need to read full ID numbers. Note the mask +shows the last 4 characters to anyone who can read the record. Dependencies ~~~~~~~~~~~~ diff --git a/spp_registry_encryption/static/description/index.html b/spp_registry_encryption/static/description/index.html index 70731f579..938cc5d25 100644 --- a/spp_registry_encryption/static/description/index.html +++ b/spp_registry_encryption/static/description/index.html @@ -370,27 +370,49 @@

    OpenSPP Registry PII Display

    !! source digest: sha256:4131b9e92df89de6365ed994e6a69506d557e2215da2a8cf07b9d69911b46ff4 !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -->

    Alpha License: LGPL-3 OpenSPP/OpenSPP2

    -

    Applies OpenSPP’s PII display protections to the registry: registrant ID +

    Applies OpenSPP’s PII display de-emphasis to the registry: registrant ID numbers (national IDs, passports, and other spp.registry.id values) render masked in the Identity tabs of the individual and group forms, -with an audited reveal for authorized users.

    +with an audited reveal control.

    What it does

    • ID numbers display as ••••-••••-1234 (mask pattern -****-****-####) instead of plaintext
    • -
    • Clicking reveal shows the value only for members of +****-****-####, matching the mask the classification registry +seeds for national IDs) instead of plaintext
    • +
    • The reveal control checks membership in spp_data_classification.group_pii_full_access_admin — the PII access group the RESTRICTED classification level points at
    • -
    • Every reveal is recorded in the PII access audit log -(spp.pii.audit.log, action reveal)
    • +
    • Every reveal through the control is recorded in the PII access audit +log (spp.pii.audit.log, action reveal)
    +
    +

    What it is — and is not

    +

    This is display de-emphasis, not an access control (see the “widget +honesty” findings in the spp_pii_encryption hardening tracker):

    +
      +
    • The masking applies to readonly display. Entering the editable list +cell shows the value in the input, without a group check or an audit +entry.
    • +
    • The value is delivered to the browser by the normal record read +regardless of mask state; the widget controls presentation, not data +access.
    • +
    • The real access boundary remains the record’s ACLs and record rules: +whoever can read/write spp.registry.id can obtain the value.
    • +
    • What the mask does buy: protection against shoulder-surfing and casual +over-exposure in day-to-day screens, plus an audit trail for +deliberate reveals through the control.
    • +
    +

    Server-side field-level enforcement is tracked in the +spp_pii_encryption hardening issue; when it lands, this module is +where the registry adopts it.

    +

    What it does NOT do

    -

    Display masking only. The stored values are not encrypted by this module -— applying spp.encrypted.field.mixin to spp.registry.id -(blind-index search, encrypted storage) is a separate change with +

    The stored values are not encrypted by this module — applying +spp.encrypted.field.mixin to spp.registry.id (blind-index +search, encrypted storage) is a separate change with search/deduplication impact.

    @@ -402,9 +424,10 @@

    UI Location

    Security

    -

    No models and no ACLs of its own. Reveal authorization uses -spp_data_classification.group_pii_full_access_admin; grant that -group to the officers who legitimately need to read full ID numbers.

    +

    No models and no ACLs of its own. The reveal control’s group is +spp_data_classification.group_pii_full_access_admin; grant it to +officers who legitimately need to read full ID numbers. Note the mask +shows the last 4 characters to anyone who can read the record.

    Dependencies

    From 6c42127aeb1619f3dfbfd93240a0aacc9419dcf7 Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Tue, 25 Aug 2026 15:36:22 +0800 Subject: [PATCH 6/7] fix(spp_pii_encryption): make masked_char safe inside editable lists Two leaks found while browser-testing the first real wiring (spp_registry_encryption, individual form Identity tab): - The reveal toggle click bubbled to the list cell, so the "gate" button itself opened the row editor and exposed the plaintext input with no permission check and no audit entry. The click now stays on the toggle (t-on-click.stop); entering edit mode remains possible by clicking the cell directly, which is the documented de-emphasis behavior. - The list renderer copies every char cell's formatted (raw) value into the cell's data-tooltip for truncated columns, so hovering a masked cell showed the full plaintext. masked_char columns now get no cell tooltip (ListRenderer.getCellTitle patch). Verified in a live browser (headless Chrome against the dev stack): masked render in the editable list, reveal refused without the PII group (toast, no audit row), reveal + spp.pii.audit.log row with it, edit-through-widget persists and re-masks on save; no console errors. --- spp_pii_encryption/readme/HISTORY.md | 6 ++++++ spp_pii_encryption/static/src/js/masked_field.js | 15 +++++++++++++++ .../static/src/xml/masked_field.xml | 2 +- 3 files changed, 22 insertions(+), 1 deletion(-) diff --git a/spp_pii_encryption/readme/HISTORY.md b/spp_pii_encryption/readme/HISTORY.md index 5744c615e..159109277 100644 --- a/spp_pii_encryption/readme/HISTORY.md +++ b/spp_pii_encryption/readme/HISTORY.md @@ -15,6 +15,12 @@ - fix: give `spp.field.encryption.config`'s `model_name` an explicit "Model Name" label — the related field inherited ir.model's "Model" string and made Odoo warn about a label clash on every registry load +- fix(security): clicking the masked-field reveal toggle inside an editable list no longer opens + the row editor — the click used to bubble to the cell and expose the plaintext input without a + permission check or an audit entry +- fix(security): list cells rendering a `masked_char` column no longer carry the raw value in + their hover tooltip (the list renderer copies formatted char values into `data-tooltip`, which + bypassed the mask entirely) ### 19.0.1.0.0 diff --git a/spp_pii_encryption/static/src/js/masked_field.js b/spp_pii_encryption/static/src/js/masked_field.js index 01a1f09fd..7efff3ced 100644 --- a/spp_pii_encryption/static/src/js/masked_field.js +++ b/spp_pii_encryption/static/src/js/masked_field.js @@ -2,11 +2,26 @@ import {registry} from "@web/core/registry"; import {CharField} from "@web/views/fields/char/char_field"; +import {ListRenderer} from "@web/views/list/list_renderer"; +import {patch} from "@web/core/utils/patch"; 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"; +// The list renderer copies every char cell's FORMATTED (raw) value into the +// cell's data-tooltip so truncated columns stay readable — which would leak +// the plaintext of a masked column on hover, bypassing the widget entirely. +// Masked columns get no tooltip. +patch(ListRenderer.prototype, { + getCellTitle(column, record) { + if (column.widget === "masked_char") { + return undefined; + } + return super.getCellTitle(column, record); + }, +}); + /** * MaskedCharField - A field widget that displays masked PII values * with the ability to reveal the actual value for authorized users. diff --git a/spp_pii_encryption/static/src/xml/masked_field.xml b/spp_pii_encryption/static/src/xml/masked_field.xml index 367e76169..5c4a86bde 100644 --- a/spp_pii_encryption/static/src/xml/masked_field.xml +++ b/spp_pii_encryption/static/src/xml/masked_field.xml @@ -15,7 +15,7 @@ t-if="props.record.data[props.name]" type="button" class="btn btn-link btn-sm p-0 ms-2 o_masked_toggle" - t-on-click="toggleReveal" + t-on-click.stop="toggleReveal" t-att-disabled="state.isLoading" t-att-title="state.isRevealed ? 'Hide value' : 'Reveal value'" > From fa7e3bb4506399a947dae5a87e9f4b86ed992dc6 Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Tue, 25 Aug 2026 15:42:47 +0800 Subject: [PATCH 7/7] docs(spp_pii_encryption): regenerate README from updated fragments Applied verbatim from CI pre-commit run 32822423156. --- spp_pii_encryption/README.rst | 8 ++++++++ spp_pii_encryption/static/description/index.html | 8 ++++++++ 2 files changed, 16 insertions(+) diff --git a/spp_pii_encryption/README.rst b/spp_pii_encryption/README.rst index 5c41fcc76..1b5d8dbb4 100644 --- a/spp_pii_encryption/README.rst +++ b/spp_pii_encryption/README.rst @@ -155,6 +155,14 @@ Changelog - fix: give ``spp.field.encryption.config``'s ``model_name`` an explicit "Model Name" label — the related field inherited ir.model's "Model" string and made Odoo warn about a label clash on every registry load +- fix(security): clicking the masked-field reveal toggle inside an + editable list no longer opens the row editor — the click used to + bubble to the cell and expose the plaintext input without a permission + check or an audit entry +- fix(security): list cells rendering a ``masked_char`` column no longer + carry the raw value in their hover tooltip (the list renderer copies + formatted char values into ``data-tooltip``, which bypassed the mask + entirely) 19.0.1.0.0 ~~~~~~~~~~ diff --git a/spp_pii_encryption/static/description/index.html b/spp_pii_encryption/static/description/index.html index 825f76f74..2fc252d13 100644 --- a/spp_pii_encryption/static/description/index.html +++ b/spp_pii_encryption/static/description/index.html @@ -528,6 +528,14 @@

    19.0.2.0.0

  • fix: give spp.field.encryption.config’s model_name an explicit “Model Name” label — the related field inherited ir.model’s “Model” string and made Odoo warn about a label clash on every registry load
  • +
  • fix(security): clicking the masked-field reveal toggle inside an +editable list no longer opens the row editor — the click used to +bubble to the cell and expose the plaintext input without a permission +check or an audit entry
  • +
  • fix(security): list cells rendering a masked_char column no longer +carry the raw value in their hover tooltip (the list renderer copies +formatted char values into data-tooltip, which bypassed the mask +entirely)