diff --git a/spp_pii_encryption/README.rst b/spp_pii_encryption/README.rst index 7d0b39a0b..1b5d8dbb4 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,47 @@ 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 +- 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 +~~~~~~~~~~ + +- 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/__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..159109277 --- /dev/null +++ b/spp_pii_encryption/readme/HISTORY.md @@ -0,0 +1,28 @@ +### 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 +- 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 + +- 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/static/description/index.html b/spp_pii_encryption/static/description/index.html index cc58ec698..2fc252d13 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,54 @@

    Dependencies

    Table of contents

    + +
    +
    +

    19.0.2.0.0

    +
    +
    +

    19.0.1.0.0

    +
    -

    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 +554,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_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'" > 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() diff --git a/spp_registry_encryption/README.rst b/spp_registry_encryption/README.rst new file mode 100644 index 000000000..6d7535c11 --- /dev/null +++ b/spp_registry_encryption/README.rst @@ -0,0 +1,134 @@ +============================ +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 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 + ``****-****-####``, 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 +~~~~~~~~~~~~~~~~~~~ + +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. 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 +~~~~~~~~~~~~ + +``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/__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..f6dc806eb --- /dev/null +++ b/spp_registry_encryption/readme/DESCRIPTION.md @@ -0,0 +1,35 @@ +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 `****-****-####`, 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 + +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. 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 + +`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 000000000..c7dbdaaf1 Binary files /dev/null and b/spp_registry_encryption/static/description/icon.png differ diff --git a/spp_registry_encryption/static/description/index.html b/spp_registry_encryption/static/description/index.html new file mode 100644 index 000000000..938cc5d25 --- /dev/null +++ b/spp_registry_encryption/static/description/index.html @@ -0,0 +1,478 @@ + + + + + +OpenSPP Registry PII Display + + + +
    +

    OpenSPP Registry PII Display

    + + +

    Alpha License: LGPL-3 OpenSPP/OpenSPP2

    +

    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 +****-****-####, 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

    +

    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. 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

    +

    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.

    +
    +
    +
    +
    + + diff --git a/spp_registry_encryption/tests/__init__.py b/spp_registry_encryption/tests/__init__.py new file mode 100644 index 000000000..73a87731b --- /dev/null +++ b/spp_registry_encryption/tests/__init__.py @@ -0,0 +1,2 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. +from . import test_view_wiring diff --git a/spp_registry_encryption/tests/test_view_wiring.py b/spp_registry_encryption/tests/test_view_wiring.py new file mode 100644 index 000000000..4eea18a5e --- /dev/null +++ b/spp_registry_encryption/tests/test_view_wiring.py @@ -0,0 +1,39 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. +"""The individuals form renders ID numbers through the masked widget.""" + +from lxml import etree + +from odoo import Command +from odoo.tests.common import TransactionCase + + +class TestMaskedIdWiring(TransactionCase): + def test_individuals_form_masks_id_value(self): + """Both Identity tabs (individual and group) render reg_ids.value + with the masked_char widget and the PII reveal gate. get_view also + validates the inherited arch, so this fails fast if view validation + rejects the widget attributes.""" + user = self.env["res.users"].create( + { + "name": "Registry Viewer", + "login": "registry_viewer_masked", + "group_ids": [Command.link(self.env.ref("base.group_user").id)], + } + ) + view = self.env.ref("spp_registry.view_individuals_form") + arch = self.env["res.partner"].with_user(user).get_view(view_id=view.id)["arch"] + tree = etree.fromstring(arch) + + value_fields = tree.xpath("//field[@name='reg_ids']/list/field[@name='value']") + self.assertEqual(len(value_fields), 2, "both Identity tabs must be wired") + for node in value_fields: + self.assertEqual(node.get("widget"), "masked_char") + self.assertEqual(node.get("mask_pattern"), "****-****-####") + self.assertEqual( + node.get("reveal_group"), + "spp_data_classification.group_pii_full_access_admin", + ) + + def test_reveal_group_exists(self): + """Guards against a rename of the PR2 group this module gates on.""" + self.assertTrue(self.env.ref("spp_data_classification.group_pii_full_access_admin")) diff --git a/spp_registry_encryption/views/registry_id_views.xml b/spp_registry_encryption/views/registry_id_views.xml new file mode 100644 index 000000000..17af3ffad --- /dev/null +++ b/spp_registry_encryption/views/registry_id_views.xml @@ -0,0 +1,38 @@ + + + + + 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 + + + +