From 3d350cec1af1406ede23c304340a4edf6d5885bb Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Wed, 10 Jun 2026 14:43:16 +0800 Subject: [PATCH 1/4] feat(spp_data_classification): port classification registry from openspp-modules Migrate the data-classification foundation to OpenSPP2 (Odoo 19), scoped to the classification registry that downstream encryption/audit consumers need: - spp.data.classification.level: sensitivity levels (PUBLIC..RESTRICTED) with policy flags - spp.field.classification: maps model fields to levels + PII category; adds is_pii (computed-stored from pii_category) so consumers can query PII fields - spp.classification.pattern: regex/CEL auto-detection patterns (CEL degrades gracefully when spp.cel.service is absent) Depends on base, spp_security. Seeds 4 levels + 15 regex detection patterns. Deferred to a separate governance PR (they patch core ORM / carry untested surface): pii_aware_mixin (base.read() masking + Field monkeypatch), DSAR, data retention + cron, and consent integration. Realises ADR-011. Verified: 19/19 tests pass (incl. new is_pii test), module installs, pre-commit (compliance hooks + bandit + semgrep) green. --- spp_data_classification/README.rst | 172 ++++++ spp_data_classification/__init__.py | 2 + spp_data_classification/__manifest__.py | 37 ++ .../data/classification_levels.xml | 71 +++ .../data/detection_patterns.xml | 190 ++++++ spp_data_classification/models/__init__.py | 4 + .../models/classification_level.py | 144 +++++ .../models/classification_pattern.py | 481 ++++++++++++++++ .../models/field_classification.py | 296 ++++++++++ spp_data_classification/pyproject.toml | 3 + spp_data_classification/readme/DESCRIPTION.md | 55 ++ .../security/ir.model.access.csv | 9 + .../security/security_groups.xml | 69 +++ .../static/description/icon.png | Bin 0 -> 15480 bytes .../static/description/index.html | 542 ++++++++++++++++++ spp_data_classification/tests/__init__.py | 4 + .../tests/test_classification_level.py | 101 ++++ .../tests/test_classification_pattern.py | 109 ++++ .../tests/test_field_classification.py | 190 ++++++ .../views/classification_level_views.xml | 102 ++++ .../views/classification_pattern_views.xml | 212 +++++++ .../views/field_classification_views.xml | 153 +++++ spp_data_classification/views/menu.xml | 39 ++ 23 files changed, 2985 insertions(+) create mode 100644 spp_data_classification/README.rst create mode 100644 spp_data_classification/__init__.py create mode 100644 spp_data_classification/__manifest__.py create mode 100644 spp_data_classification/data/classification_levels.xml create mode 100644 spp_data_classification/data/detection_patterns.xml create mode 100644 spp_data_classification/models/__init__.py create mode 100644 spp_data_classification/models/classification_level.py create mode 100644 spp_data_classification/models/classification_pattern.py create mode 100644 spp_data_classification/models/field_classification.py create mode 100644 spp_data_classification/pyproject.toml create mode 100644 spp_data_classification/readme/DESCRIPTION.md create mode 100644 spp_data_classification/security/ir.model.access.csv create mode 100644 spp_data_classification/security/security_groups.xml create mode 100644 spp_data_classification/static/description/icon.png create mode 100644 spp_data_classification/static/description/index.html create mode 100644 spp_data_classification/tests/__init__.py create mode 100644 spp_data_classification/tests/test_classification_level.py create mode 100644 spp_data_classification/tests/test_classification_pattern.py create mode 100644 spp_data_classification/tests/test_field_classification.py create mode 100644 spp_data_classification/views/classification_level_views.xml create mode 100644 spp_data_classification/views/classification_pattern_views.xml create mode 100644 spp_data_classification/views/field_classification_views.xml create mode 100644 spp_data_classification/views/menu.xml diff --git a/spp_data_classification/README.rst b/spp_data_classification/README.rst new file mode 100644 index 000000000..07110f3ac --- /dev/null +++ b/spp_data_classification/README.rst @@ -0,0 +1,172 @@ +=========================== +OpenSPP Data Classification +=========================== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:4ef670165bef57cbeae9b91f0e6599471121b01cc624df1e6da3bf3c1a6c327d + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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_data_classification + :alt: OpenSPP/OpenSPP2 + +|badge1| |badge2| |badge3| + +Data sensitivity classification registry for OpenSPP. Defines +classification levels, maps model fields to those levels with PII +categorization, and provides regex/CEL auto-detection patterns. This is +the foundation other modules consume to decide what to encrypt, mask, or +audit. + +Key Capabilities +~~~~~~~~~~~~~~~~ + +- Define sensitivity levels (PUBLIC, INTERNAL, CONFIDENTIAL, RESTRICTED) + with encryption, masking, audit, consent, and retention policy flags +- Map model fields to classification levels with PII categorization + (direct identifiers, quasi-identifiers, biometric, health, financial, + etc.) +- Flag classified fields as PII (``is_pii``, derived from the assigned + PII category) so consumers can query them +- Auto-classify fields using regex (or CEL) patterns that match field + names and metadata, via a manually-invoked scan + +Key Models +~~~~~~~~~~ + ++-----------------------------------+----------------------------------+ +| Model | Description | ++===================================+==================================+ +| ``spp.data.classification.level`` | Sensitivity levels with | +| | encryption, masking, and | +| | retention policies | ++-----------------------------------+----------------------------------+ +| ``spp.field.classification`` | Maps specific model fields to | +| | classification levels and PII | +| | category | ++-----------------------------------+----------------------------------+ +| ``spp.classification.pattern`` | Auto-detection patterns using | +| | regex or CEL expressions | ++-----------------------------------+----------------------------------+ + +Configuration +~~~~~~~~~~~~~ + +After installing: + +1. Navigate to **Settings > Data Classification > Classification + Levels** to review pre-loaded sensitivity levels (PUBLIC, INTERNAL, + CONFIDENTIAL, RESTRICTED) +2. Review **Detection Patterns** to customize auto-classification rules + for your organization +3. Map fields under **Field Classifications** + +UI Location +~~~~~~~~~~~ + +- **Menu**: Settings > Data Classification +- **Classification Levels**: Settings > Data Classification > + Classification Levels +- **Field Classifications**: Settings > Data Classification > Field + Classifications +- **Detection Patterns**: Settings > Data Classification > Detection + Patterns + +Security +~~~~~~~~ + ++----------------------------------------------------------+----------------------------------+ +| Group | Access | ++==========================================================+==================================+ +| ``spp_data_classification.group_classification_admin`` | Full CRUD on levels, patterns, | +| | and field classifications | ++----------------------------------------------------------+----------------------------------+ +| ``spp_data_classification.group_classification_manager`` | Read/Write/Create field | +| | classifications (no delete); | +| | read-only on levels and patterns | ++----------------------------------------------------------+----------------------------------+ +| ``base.group_user`` | Read-only access to | +| | classifications | ++----------------------------------------------------------+----------------------------------+ + +The ``pii_full_access`` and ``pii_confidential_access`` groups are +defined for downstream masking/access-control consumers. + +Extension Points +~~~~~~~~~~~~~~~~ + +- Query ``spp.field.classification`` to discover classified/PII fields + per model (e.g. ``get_fields_requiring_encryption()``) +- Override ``spp.classification.pattern.matches_field()`` to implement + custom detection logic +- Add custom PII categories by extending the ``pii_category`` selection + field + +Deferred subsystems +~~~~~~~~~~~~~~~~~~~ + +PII-aware enforcement (automatic read-time masking + the +``pii=``/``classification=`` field attributes), GDPR DSAR handling, +data-retention scheduling, and consent integration are part of this +module's design but are delivered in a separate governance change. This +module ships the classification registry only. + +Dependencies +~~~~~~~~~~~~ + +``base``, ``spp_security`` + +.. IMPORTANT:: + This is an alpha version, the data model and design can change at any time without warning. + Only for development or testing purpose, do not use in production. + +**Table of contents** + +.. contents:: + :local: + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +------- + +* OpenSPP.org + +Maintainers +----------- + +.. |maintainer-jeremi| image:: https://github.com/jeremi.png?size=40px + :target: https://github.com/jeremi + :alt: jeremi +.. |maintainer-gonzalesedwin1123| image:: https://github.com/gonzalesedwin1123.png?size=40px + :target: https://github.com/gonzalesedwin1123 + :alt: gonzalesedwin1123 + +Current maintainers: + +|maintainer-jeremi| |maintainer-gonzalesedwin1123| + +This module is part of the `OpenSPP/OpenSPP2 `_ project on GitHub. + +You are welcome to contribute. \ No newline at end of file diff --git a/spp_data_classification/__init__.py b/spp_data_classification/__init__.py new file mode 100644 index 000000000..d33610325 --- /dev/null +++ b/spp_data_classification/__init__.py @@ -0,0 +1,2 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. +from . import models diff --git a/spp_data_classification/__manifest__.py b/spp_data_classification/__manifest__.py new file mode 100644 index 000000000..1c774a2b6 --- /dev/null +++ b/spp_data_classification/__manifest__.py @@ -0,0 +1,37 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. +# pylint: disable=pointless-statement +{ + "name": "OpenSPP Data Classification", + "summary": "Data sensitivity classification and PII protection for OpenSPP", + "category": "OpenSPP/Configuration", + "version": "19.0.1.0.0", + "sequence": 1, + "author": "OpenSPP.org", + "website": "https://github.com/OpenSPP/OpenSPP2", + "license": "LGPL-3", + "development_status": "Alpha", + "maintainers": ["jeremi", "gonzalesedwin1123"], + "depends": [ + "base", + "spp_security", + ], + "external_dependencies": { + "python": [], + }, + "data": [ + "security/security_groups.xml", + "security/ir.model.access.csv", + "data/classification_levels.xml", + "data/detection_patterns.xml", + "views/field_classification_views.xml", + "views/classification_level_views.xml", + "views/classification_pattern_views.xml", + "views/menu.xml", + ], + "assets": {}, + "demo": [], + "images": [], + "application": False, + "installable": True, + "auto_install": False, +} diff --git a/spp_data_classification/data/classification_levels.xml b/spp_data_classification/data/classification_levels.xml new file mode 100644 index 000000000..41f09f5f9 --- /dev/null +++ b/spp_data_classification/data/classification_levels.xml @@ -0,0 +1,71 @@ + + + + + + Public + PUBLIC + 10 + 10 + Data that can be freely shared. No restrictions on access or distribution. + False + False + False + False + 0 + none + + + + Internal + INTERNAL + 20 + 4 + Data for internal use only. Should not be shared externally without authorization. + False + False + True + False + 0 + none + + + + Confidential + CONFIDENTIAL + 30 + 3 + Sensitive personal data. Access restricted to authorized personnel. Recommended encryption. + False + True + True + True + + 2555 + anonymize + + + + Restricted + RESTRICTED + 40 + 1 + Highly sensitive data (IDs, financial info). Mandatory encryption. Strict access control. + True + True + True + True + True + + 2555 + delete + + diff --git a/spp_data_classification/data/detection_patterns.xml b/spp_data_classification/data/detection_patterns.xml new file mode 100644 index 000000000..2b26c1c52 --- /dev/null +++ b/spp_data_classification/data/detection_patterns.xml @@ -0,0 +1,190 @@ + + + + + + + + + National/Government ID + (national|passport|ssn|social.?security|tax|identity|citizen).*id + + direct_id + 95 + ****-****-#### + blind_index + Matches national_id, passport_id, ssn_id, tax_id, etc. + + + + Bank Account Numbers + (account|iban|bank|routing|swift).*n?(o|um|umber)? + + financial + 95 + ****-****-#### + blind_index + Matches account_number, iban, bank_no, routing_number, etc. + + + + Biometric Data + (fingerprint|biometric|face.?id|iris|retina|voice.?print) + + biometric + 95 + none + Matches fingerprint, biometric_data, face_id, etc. + + + + Health Information + (health|medical|diagnosis|disability|condition|treatment|medication) + + health + 90 + none + Matches health_status, medical_condition, disability_type, etc. + + + + Political/Religious + (religion|religious|ethnic|ethnicity|race|racial|political|union.?member) + + political + 90 + none + GDPR Article 9 special categories + + + + + + Personal Names + (family|given|first|last|middle|maiden|sur).*name|^name$ + + direct_id + 80 + phonetic + Matches family_name, given_name, first_name, surname, etc. + + + + Birth Date + (birth|dob|date.?of.?birth|born) + + quasi_id + 75 + range + Matches birthdate, dob, date_of_birth, birth_date, etc. + + + + Phone Numbers + (phone|mobile|cell|tel|fax|whatsapp) + + contact + 70 + ***-***-#### + partial_index + Matches phone, mobile_phone, telephone, cell_number, etc. + + + + Physical Address + (address|street|city|postal|zip|province|district|village|barangay) + + contact + 70 + blind_index + Matches address, street_address, postal_code, city, etc. + + + + Income/Salary + (income|salary|wage|earning|revenue) + + financial + 70 + range + Matches income, salary, monthly_wage, earnings, etc. + + + + GPS/Location + (gps|latitude|longitude|geo.?location|coordinates) + + location + 65 + range + Matches gps_coordinates, latitude, longitude, etc. + + + + + + Email Address + e?.?mail + + contact + 50 + blind_index + Matches email, e_mail, email_address, etc. + + + + Gender + (gender|sex) + + quasi_id + 40 + full + Matches gender, sex, gender_id, etc. + + + + Marital Status + (marital|civil).?(status|state) + + quasi_id + 40 + full + Matches marital_status, civil_status, etc. + + + + Occupation + (occupation|profession|job|employment) + + quasi_id + 40 + full + Matches occupation, profession, job_title, etc. + + diff --git a/spp_data_classification/models/__init__.py b/spp_data_classification/models/__init__.py new file mode 100644 index 000000000..9bb81ced4 --- /dev/null +++ b/spp_data_classification/models/__init__.py @@ -0,0 +1,4 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. +from . import classification_level +from . import field_classification +from . import classification_pattern diff --git a/spp_data_classification/models/classification_level.py b/spp_data_classification/models/classification_level.py new file mode 100644 index 000000000..55565d214 --- /dev/null +++ b/spp_data_classification/models/classification_level.py @@ -0,0 +1,144 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. +import logging + +from odoo import _, api, fields, models +from odoo.exceptions import UserError + +_logger = logging.getLogger(__name__) + + +class DataClassificationLevel(models.Model): + """Data sensitivity classification levels. + + Defines the sensitivity levels for PII fields (e.g., PUBLIC, INTERNAL, + CONFIDENTIAL, RESTRICTED) along with policies that apply at each level. + + Usage: + Levels are typically defined via XML data and referenced by + field classifications. Each level carries policy flags that + determine how data should be handled. + """ + + _name = "spp.data.classification.level" + _description = "Data Classification Level" + _order = "sequence, id" + + name = fields.Char( + required=True, + translate=True, + help="Human-readable name for this classification level", + ) + code = fields.Char( + required=True, + help="Technical code (e.g., PUBLIC, INTERNAL, CONFIDENTIAL, RESTRICTED)", + ) + sequence = fields.Integer( + default=10, + help="Higher sequence = more sensitive. Used for access control comparison.", + ) + color = fields.Integer( + default=0, + help="Color index for UI display", + ) + description = fields.Text( + translate=True, + help="Detailed description of when to use this classification", + ) + active = fields.Boolean(default=True) + + # === Policy Flags === + is_requires_encryption = fields.Boolean( + string="Requires Encryption", + default=False, + help="Fields at this level must be encrypted at rest", + ) + is_requires_masking = fields.Boolean( + string="Requires Masking", + default=False, + help="Fields displayed with masking by default (e.g., ****1234)", + ) + is_requires_audit = fields.Boolean( + string="Requires Audit", + default=False, + help="All access to fields at this level is logged", + ) + is_requires_consent = fields.Boolean( + string="Requires Consent", + default=False, + help="Explicit consent required before collection/processing", + ) + is_requires_purpose_limitation = fields.Boolean( + string="Requires Purpose Limitation", + default=False, + help="Data can only be used for stated purposes", + ) + + # === Retention === + retention_days = fields.Integer( + string="Retention Period (Days)", + default=0, + help="Auto-archive/delete after N days. 0 = indefinite retention.", + ) + retention_action = fields.Selection( + [ + ("none", "No Action"), + ("anonymize", "Anonymize"), + ("archive", "Archive"), + ("delete", "Delete"), + ], + string="Retention Action", + default="none", + help="Action to take when retention period expires", + ) + + # === Access Control === + min_group_id = fields.Many2one( + "res.groups", + string="Minimum Access Group", + help="Minimum security group required to view unmasked data at this level", + ) + + _unique_code = models.Constraint( + "UNIQUE(code)", + "Classification code must be unique", + ) + + def unlink(self): + """Prevent deletion of levels that are in use.""" + for record in self: + field_count = self.env["spp.field.classification"].search_count([("classification_id", "=", record.id)]) + if field_count > 0: + raise UserError( + _( + "Cannot delete classification level '%(name)s' - " + "it is used by %(count)d field classification(s)." + ) + % {"name": record.name, "count": field_count} + ) + return super().unlink() + + @api.model + def get_level_by_code(self, code): + """Get classification level by code. + + Args: + code: The classification code (e.g., 'RESTRICTED') + + Returns: + recordset: The matching classification level or empty recordset + """ + return self.search([("code", "=", code)], limit=1) + + def is_more_sensitive_than(self, other_level): + """Compare sensitivity between two levels. + + Args: + other_level: Another classification level to compare against + + Returns: + bool: True if this level is more sensitive (higher sequence) + """ + self.ensure_one() + if not other_level: + return True + return self.sequence > other_level.sequence diff --git a/spp_data_classification/models/classification_pattern.py b/spp_data_classification/models/classification_pattern.py new file mode 100644 index 000000000..8c6f29856 --- /dev/null +++ b/spp_data_classification/models/classification_pattern.py @@ -0,0 +1,481 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. +import functools +import logging +import re +from types import SimpleNamespace + +from odoo import _, api, fields, models +from odoo.exceptions import ValidationError + +_logger = logging.getLogger(__name__) + + +@functools.lru_cache(maxsize=256) +def _compile_regex(pattern_str): + """Compile regex pattern with caching. + + Uses functools.lru_cache for thread-safe caching. + """ + try: + return re.compile(pattern_str, re.IGNORECASE) + except re.error as e: + _logger.warning("Invalid regex pattern '%s': %s", pattern_str, e) + return None + + +class ClassificationPattern(models.Model): + """Auto-detection patterns for PII fields. + + Supports two matching modes: + 1. Regex (default): Simple pattern matching against field names + 2. CEL: Complex expressions using field metadata (type, name, model, etc.) + + CEL expressions have access to: + - field.name: Field name (str) + - field.type: Field type like 'char', 'integer', etc. (str) + - field.store: Whether field is stored in DB (bool) + - field.required: Whether field is required (bool) + - model.model: Model technical name like 'res.partner' (str) + - model.name: Model display name (str) + + Example CEL expressions: + - field.type == 'char' && field.name.endsWith('_id') + - model.model.startsWith('spp.') && field.name matches 'national.*' + - field.type in ['char', 'text'] && field.store + + Usage: + Patterns are evaluated in priority order (higher = first). + When a field matches, it's automatically classified. + """ + + _name = "spp.classification.pattern" + _description = "Classification Auto-Detection Pattern" + _order = "priority desc, id" + + name = fields.Char( + required=True, + help="Descriptive name for this pattern", + ) + + # === Matching Mode === + match_mode = fields.Selection( + [ + ("regex", "Regex Pattern"), + ("cel", "CEL Expression"), + ], + string="Match Mode", + default="regex", + required=True, + help="How to match fields: Regex for simple name patterns, CEL for complex rules", + ) + + # Regex pattern (legacy/simple mode) + pattern = fields.Char( + help="Regex pattern to match field names. Case-insensitive. " + "E.g., '(national|passport|tax).*id' matches 'national_id', 'passport_id'", + ) + + # CEL expression (advanced mode) + cel_expression = fields.Text( + string="CEL Expression", + help="CEL expression for complex field matching. " + "Use 'field' for field metadata and 'model' for model metadata.\n" + "Example: field.type == 'char' && field.name matches 'national.*'", + ) + + classification_id = fields.Many2one( + "spp.data.classification.level", + string="Classification Level", + required=True, + ondelete="restrict", + help="Classification level to apply when pattern matches", + ) + pii_category = fields.Selection( + [ + ("direct_id", "Direct Identifier"), + ("quasi_id", "Quasi-Identifier"), + ("sensitive", "Sensitive Personal Data"), + ("financial", "Financial Information"), + ("contact", "Contact Information"), + ("biometric", "Biometric Data"), + ("health", "Health Information"), + ("political", "Political/Religious/Union"), + ("genetic", "Genetic Data"), + ("location", "Location Data"), + ], + string="PII Category", + help="PII category to assign when pattern matches", + ) + priority = fields.Integer( + default=50, + help="Higher priority patterns are evaluated first (0-100)", + ) + active = fields.Boolean(default=True) + + # === Default Handling === + default_mask_pattern = fields.Char( + string="Default Mask Pattern", + help="Mask pattern to apply (e.g., '****-****-####')", + ) + default_search_strategy = fields.Selection( + [ + ("none", "No Search Allowed"), + ("blind_index", "Blind Index (Exact Match)"), + ("partial_index", "Partial Index (Last N chars)"), + ("phonetic", "Phonetic Search (Names)"), + ("range", "Range Search (Dates)"), + ("full", "Full Search (Decrypted)"), + ], + string="Default Search Strategy", + default="blind_index", + ) + + # === Scope === + apply_to_model_pattern = fields.Char( + string="Model Pattern", + help="Optional: Only apply to models matching this pattern. " + "E.g., 'spp\\..*' for all OpenSPP models. Empty = all models.", + ) + + notes = fields.Text( + string="Notes", + help="Documentation about what this pattern matches", + ) + + @api.constrains("match_mode", "pattern", "cel_expression") + def _check_pattern_or_expression(self): + """Ensure either pattern or CEL expression is provided based on mode.""" + for record in self: + if record.match_mode == "regex": + if not record.pattern: + raise ValidationError(_("Regex pattern is required when using Regex match mode")) + elif record.match_mode == "cel": + if not record.cel_expression: + raise ValidationError(_("CEL expression is required when using CEL match mode")) + + @api.model + def _get_compiled_pattern(self, pattern_str): + """Get compiled regex pattern with caching. + + Uses module-level lru_cache for thread-safe caching. + """ + return _compile_regex(pattern_str) + + def matches_field(self, field_name, model_name=None, field_obj=None, model_obj=None): + """Check if this pattern matches a field. + + Args: + field_name: The field name to check + model_name: Optional model name for scope filtering + field_obj: Optional ir.model.fields record for CEL evaluation + model_obj: Optional ir.model record for CEL evaluation + + Returns: + bool: True if pattern matches + """ + self.ensure_one() + + # Check model scope if specified (applies to both modes) + if self.apply_to_model_pattern and model_name: + model_pattern = self._get_compiled_pattern(self.apply_to_model_pattern) + if model_pattern and not model_pattern.search(model_name): + return False + + # Dispatch to appropriate matcher based on mode + if self.match_mode == "cel": + return self._matches_field_cel(field_name, model_name, field_obj, model_obj) + else: + return self._matches_field_regex(field_name) + + def _matches_field_regex(self, field_name): + """Check if field name matches regex pattern. + + Args: + field_name: The field name to check + + Returns: + bool: True if pattern matches + """ + if not self.pattern: + return False + + pattern = self._get_compiled_pattern(self.pattern) + if pattern: + return bool(pattern.search(field_name)) + return False + + def _matches_field_cel(self, field_name, model_name=None, field_obj=None, model_obj=None): + """Check if field matches CEL expression. + + Builds a context with field and model metadata, then evaluates + the CEL expression. + + Args: + field_name: The field name + model_name: The model name + field_obj: Optional ir.model.fields record + model_obj: Optional ir.model record + + Returns: + bool: True if CEL expression evaluates to True + """ + if not self.cel_expression: + return False + + # Check if CEL service is available + cel_service = self.env.get("spp.cel.service") + if not cel_service: + _logger.warning( + "CEL service not available for pattern '%s'. Install spp_cel_domain module to use CEL patterns.", + self.name, + ) + return False + + # Build context for CEL evaluation + context = self._build_cel_context(field_name, model_name, field_obj, model_obj) + + try: + result = cel_service.evaluate_expression(self.cel_expression, context) + return bool(result) + except (ValueError, TypeError, KeyError, AttributeError) as e: + _logger.warning( + "CEL evaluation failed for pattern '%s': %s", + self.name, + str(e), + ) + return False + except SyntaxError as e: + _logger.warning( + "CEL syntax error in pattern '%s': %s", + self.name, + str(e), + ) + return False + + def _build_cel_context(self, field_name, model_name=None, field_obj=None, model_obj=None): + """Build CEL evaluation context with field and model metadata. + + Args: + field_name: The field name + model_name: The model name + field_obj: Optional ir.model.fields record + model_obj: Optional ir.model record + + Returns: + dict: Context for CEL evaluation + """ + # Build field context + field_ctx = { + "name": field_name, + "type": "unknown", + "store": True, + "required": False, + "readonly": False, + "translate": False, + } + + if field_obj: + field_ctx.update( + { + "type": field_obj.ttype, + "store": field_obj.store, + "required": field_obj.required, + "readonly": field_obj.readonly, + "translate": field_obj.translate, + "help": field_obj.help or "", + "relation": field_obj.relation or "", + } + ) + + # Build model context + model_ctx = { + "model": model_name or "", + "name": "", + "transient": False, + } + + if model_obj: + model_ctx.update( + { + "model": model_obj.model, + "name": model_obj.name, + "transient": model_obj.transient, + } + ) + + # Use SimpleNamespace for dot access in CEL expressions + return { + "field": SimpleNamespace(**field_ctx), + "model": SimpleNamespace(**model_ctx), + } + + @api.model + def find_matching_pattern(self, field_name, model_name=None, field_obj=None, model_obj=None): + """Find the first (highest priority) matching pattern. + + Args: + field_name: The field name to check + model_name: Optional model name for scope filtering + field_obj: Optional ir.model.fields record for CEL evaluation + model_obj: Optional ir.model record for CEL evaluation + + Returns: + recordset: The matching pattern or empty recordset + """ + patterns = self.search([("active", "=", True)]) + for pattern in patterns: + if pattern.matches_field(field_name, model_name, field_obj, model_obj): + return pattern + return self.browse() + + @api.model + def auto_classify_field(self, model_name, field_name, field_obj=None, model_obj=None): + """Attempt to auto-classify a field based on patterns. + + Args: + model_name: The model name + field_name: The field name + field_obj: Optional ir.model.fields record for CEL evaluation + model_obj: Optional ir.model record for CEL evaluation + + Returns: + recordset: The created classification or empty if no match + """ + pattern = self.find_matching_pattern(field_name, model_name, field_obj, model_obj) + if not pattern: + return self.env["spp.field.classification"].browse() + + _logger.info( + "Auto-classifying %s.%s as %s (pattern: %s, mode: %s)", + model_name, + field_name, + pattern.classification_id.code, + pattern.name, + pattern.match_mode, + ) + + return self.env["spp.field.classification"].ensure_classification( + model_name=model_name, + field_name=field_name, + level_code=pattern.classification_id.code, + source="auto", + pattern_id=pattern.id, + pii_category=pattern.pii_category, + mask_pattern=pattern.default_mask_pattern, + search_strategy=pattern.default_search_strategy, + ) + + @api.model + def scan_model_fields(self, model_name, skip_classified=True): + """Scan all fields of a model and auto-classify matches. + + Args: + model_name: The model to scan + skip_classified: Skip fields that already have classifications + + Returns: + list: List of (field_name, classification) tuples + """ + model = self.env["ir.model"].search([("model", "=", model_name)], limit=1) + if not model: + _logger.warning("Model not found: %s", model_name) + return [] + + results = [] + for field in model.field_id: + # Skip non-data fields + if field.ttype in ("one2many", "many2many"): + continue + + # Skip if already classified + if skip_classified: + existing = self.env["spp.field.classification"].get_classification(model_name, field.name) + if existing: + continue + + # Try to auto-classify (pass field and model objects for CEL evaluation) + classification = self.auto_classify_field(model_name, field.name, field, model) + if classification: + results.append((field.name, classification)) + + return results + + @api.model + def scan_all_models(self, model_pattern=None, skip_classified=True): + """Scan all models (or matching pattern) for PII fields. + + Args: + model_pattern: Optional regex pattern to filter models + skip_classified: Skip fields that already have classifications + + Returns: + dict: {model_name: [(field_name, classification), ...]} + """ + domain = [("transient", "=", False)] + models = self.env["ir.model"].search(domain) + + if model_pattern: + pattern = self._get_compiled_pattern(model_pattern) + if pattern: + models = models.filtered(lambda m: pattern.search(m.model)) + + results = {} + for model in models: + model_results = self.scan_model_fields(model.model, skip_classified) + if model_results: + results[model.model] = model_results + + return results + + def action_test_pattern(self): + """Test this pattern against a sample model to preview matches. + + Returns: + Action to display notification with test results + """ + self.ensure_one() + + # Use res.partner as sample model + test_model = self.env["ir.model"].search([("model", "=", "res.partner")], limit=1) + if not test_model: + return { + "type": "ir.actions.client", + "tag": "display_notification", + "params": { + "title": _("Test Failed"), + "message": _("Cannot find res.partner model for testing"), + "type": "warning", + "sticky": False, + }, + } + + # Test against all fields + matching_fields = [] + for field in test_model.field_id: + if field.ttype in ("one2many", "many2many"): + continue + if self.matches_field(field.name, test_model.model, field, test_model): + matching_fields.append(field.name) + + if matching_fields: + message = _("Pattern matches %d fields:\n%s") % ( + len(matching_fields), + ", ".join(sorted(matching_fields)[:20]), + ) + if len(matching_fields) > 20: + message += _(", ... and %d more") % (len(matching_fields) - 20) + msg_type = "success" + else: + message = _("Pattern does not match any fields in res.partner") + msg_type = "warning" + + return { + "type": "ir.actions.client", + "tag": "display_notification", + "params": { + "title": _("Pattern Test Results"), + "message": message, + "type": msg_type, + "sticky": True, + }, + } diff --git a/spp_data_classification/models/field_classification.py b/spp_data_classification/models/field_classification.py new file mode 100644 index 000000000..34151a603 --- /dev/null +++ b/spp_data_classification/models/field_classification.py @@ -0,0 +1,296 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. +import logging + +from odoo import _, api, fields, models +from odoo.exceptions import ValidationError + +_logger = logging.getLogger(__name__) + + +class FieldClassification(models.Model): + """Maps model fields to classification levels. + + This is the central registry of all PII/sensitive fields in the system. + Each record links a specific field on a specific model to a classification + level and defines handling policies. + + Usage: + Classifications can be: + 1. Auto-detected from field name patterns + 2. Declared via XML data files + 3. Set via pii=True field attribute + 4. Manually configured via UI + """ + + _name = "spp.field.classification" + _description = "Field Classification" + _rec_name = "display_name" + _order = "model_id, field_id" + + model_id = fields.Many2one( + "ir.model", + string="Model", + required=True, + ondelete="cascade", + index=True, + help="The model containing the classified field", + ) + model_name = fields.Char( + related="model_id.model", + store=True, + index=True, + ) + field_id = fields.Many2one( + "ir.model.fields", + string="Field", + required=True, + ondelete="cascade", + domain="[('model_id', '=', model_id)]", + index=True, + help="The field being classified", + ) + field_name = fields.Char( + related="field_id.name", + store=True, + index=True, + ) + classification_id = fields.Many2one( + "spp.data.classification.level", + string="Classification Level", + required=True, + ondelete="restrict", + index=True, + help="The sensitivity level of this field", + ) + display_name = fields.Char( + compute="_compute_display_name", + store=True, + ) + + is_pii = fields.Boolean( + string="Is PII", + compute="_compute_is_pii", + store=True, + help="Set automatically when a PII category is assigned.", + ) + + # === PII Categorization === + pii_category = fields.Selection( + [ + ("direct_id", "Direct Identifier"), + ("quasi_id", "Quasi-Identifier"), + ("sensitive", "Sensitive Personal Data"), + ("financial", "Financial Information"), + ("contact", "Contact Information"), + ("biometric", "Biometric Data"), + ("health", "Health Information"), + ("political", "Political/Religious/Union"), + ("genetic", "Genetic Data"), + ("location", "Location Data"), + ], + string="PII Category", + help="Category of personally identifiable information", + ) + + # === Compliance Flags === + is_gdpr_special_category = fields.Boolean( + string="GDPR Special Category", + default=False, + help="GDPR Article 9 special category data (race, health, politics, etc.)", + ) + cross_border_restricted = fields.Boolean( + string="Cross-Border Restricted", + default=False, + help="Cannot be transferred outside jurisdiction without safeguards", + ) + child_data = fields.Boolean( + string="May Contain Child Data", + default=False, + help="Field may contain data about minors (requires extra protection)", + ) + + # === Handling Configuration === + mask_pattern = fields.Char( + string="Mask Pattern", + help="Display mask pattern. Use * for hidden, # for visible. E.g., '****-****-####' shows last 4 characters.", + ) + search_strategy = fields.Selection( + [ + ("none", "No Search Allowed"), + ("blind_index", "Blind Index (Exact Match)"), + ("partial_index", "Partial Index (Last N chars)"), + ("phonetic", "Phonetic Search (Names)"), + ("range", "Range Search (Dates)"), + ("full", "Full Search (Decrypted)"), + ], + string="Search Strategy", + default="blind_index", + help="How this field can be searched when encrypted", + ) + + # === Legal Basis === + legal_basis = fields.Selection( + [ + ("consent", "Consent"), + ("contract", "Contractual Necessity"), + ("legal", "Legal Obligation"), + ("vital", "Vital Interests"), + ("public", "Public Interest"), + ("legitimate", "Legitimate Interest"), + ], + string="Legal Basis", + help="Legal basis for processing this data (GDPR Article 6)", + ) + data_source = fields.Char( + string="Data Source", + help="Where this data originates (e.g., 'Government ID Document')", + ) + + # === Source Tracking === + source = fields.Selection( + [ + ("auto", "Auto-detected"), + ("attribute", "Field Attribute"), + ("xml", "XML Data"), + ("manual", "Manual"), + ], + string="Classification Source", + default="manual", + readonly=True, + help="How this classification was created", + ) + pattern_id = fields.Many2one( + "spp.classification.pattern", + string="Matched Pattern", + readonly=True, + help="If auto-detected, the pattern that matched", + ) + + notes = fields.Text( + string="Notes", + help="Additional notes about this classification", + ) + + _unique_model_field = models.Constraint( + "UNIQUE(model_id, field_id)", + "Each field can only have one classification", + ) + + @api.depends("model_id", "field_id", "classification_id") + def _compute_display_name(self): + for record in self: + if record.model_id and record.field_id: + record.display_name = ( + f"{record.model_id.model}.{record.field_id.name} [{record.classification_id.code or 'N/A'}]" + ) + else: + record.display_name = _("New Classification") + + @api.depends("pii_category") + def _compute_is_pii(self): + for record in self: + record.is_pii = bool(record.pii_category) + + @api.constrains("model_id", "field_id") + def _check_field_belongs_to_model(self): + for record in self: + if record.field_id and record.model_id: + if record.field_id.model_id != record.model_id: + raise ValidationError( + _("Field '%(field)s' does not belong to model '%(model)s'") + % { + "field": record.field_id.name, + "model": record.model_id.model, + } + ) + + @api.model + def get_classification(self, model_name, field_name): + """Get classification for a specific model field. + + Args: + model_name: The model name (e.g., 'res.partner') + field_name: The field name (e.g., 'national_id') + + Returns: + recordset: The field classification or empty recordset + """ + return self.search( + [ + ("model_name", "=", model_name), + ("field_name", "=", field_name), + ], + limit=1, + ) + + @api.model + def get_model_classifications(self, model_name): + """Get all classifications for a model. + + Args: + model_name: The model name (e.g., 'res.partner') + + Returns: + recordset: All field classifications for the model + """ + return self.search([("model_name", "=", model_name)]) + + @api.model + def get_fields_requiring_encryption(self, model_name=None): + """Get all fields that require encryption. + + Args: + model_name: Optional model name to filter by + + Returns: + recordset: Field classifications requiring encryption + """ + domain = [("classification_id.is_requires_encryption", "=", True)] + if model_name: + domain.append(("model_name", "=", model_name)) + return self.search(domain) + + @api.model + def ensure_classification(self, model_name, field_name, level_code, source="manual", **kwargs): + """Ensure a field classification exists, creating if needed. + + Args: + model_name: The model name + field_name: The field name + level_code: Classification level code (e.g., 'RESTRICTED') + source: Source of classification + **kwargs: Additional field values + + Returns: + recordset: The existing or created classification + """ + existing = self.get_classification(model_name, field_name) + if existing: + return existing + + model = self.env["ir.model"].search([("model", "=", model_name)], limit=1) + if not model: + _logger.warning("Model not found for classification: %s", model_name) + return self.browse() + + field = self.env["ir.model.fields"].search( + [("model_id", "=", model.id), ("name", "=", field_name)], + limit=1, + ) + if not field: + _logger.warning("Field not found for classification: %s.%s", model_name, field_name) + return self.browse() + + level = self.env["spp.data.classification.level"].get_level_by_code(level_code) + if not level: + _logger.warning("Classification level not found: %s", level_code) + return self.browse() + + values = { + "model_id": model.id, + "field_id": field.id, + "classification_id": level.id, + "source": source, + **kwargs, + } + return self.create(values) diff --git a/spp_data_classification/pyproject.toml b/spp_data_classification/pyproject.toml new file mode 100644 index 000000000..4231d0ccc --- /dev/null +++ b/spp_data_classification/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/spp_data_classification/readme/DESCRIPTION.md b/spp_data_classification/readme/DESCRIPTION.md new file mode 100644 index 000000000..0f03f7d1e --- /dev/null +++ b/spp_data_classification/readme/DESCRIPTION.md @@ -0,0 +1,55 @@ +Data sensitivity classification registry for OpenSPP. Defines classification levels, maps model fields to those levels with PII categorization, and provides regex/CEL auto-detection patterns. This is the foundation other modules consume to decide what to encrypt, mask, or audit. + +### Key Capabilities + +- Define sensitivity levels (PUBLIC, INTERNAL, CONFIDENTIAL, RESTRICTED) with encryption, masking, audit, consent, and retention policy flags +- Map model fields to classification levels with PII categorization (direct identifiers, quasi-identifiers, biometric, health, financial, etc.) +- Flag classified fields as PII (`is_pii`, derived from the assigned PII category) so consumers can query them +- Auto-classify fields using regex (or CEL) patterns that match field names and metadata, via a manually-invoked scan + +### Key Models + +| Model | Description | +| ---------------------------------- | -------------------------------------------------------------------- | +| `spp.data.classification.level` | Sensitivity levels with encryption, masking, and retention policies | +| `spp.field.classification` | Maps specific model fields to classification levels and PII category | +| `spp.classification.pattern` | Auto-detection patterns using regex or CEL expressions | + +### Configuration + +After installing: + +1. Navigate to **Settings > Data Classification > Classification Levels** to review pre-loaded sensitivity levels (PUBLIC, INTERNAL, CONFIDENTIAL, RESTRICTED) +2. Review **Detection Patterns** to customize auto-classification rules for your organization +3. Map fields under **Field Classifications** + +### UI Location + +- **Menu**: Settings > Data Classification +- **Classification Levels**: Settings > Data Classification > Classification Levels +- **Field Classifications**: Settings > Data Classification > Field Classifications +- **Detection Patterns**: Settings > Data Classification > Detection Patterns + +### Security + +| Group | Access | +| ------------------------------------------------------ | -------------------------------------------------------- | +| `spp_data_classification.group_classification_admin` | Full CRUD on levels, patterns, and field classifications | +| `spp_data_classification.group_classification_manager` | Read/Write/Create field classifications (no delete); read-only on levels and patterns | +| `base.group_user` | Read-only access to classifications | + +The `pii_full_access` and `pii_confidential_access` groups are defined for downstream masking/access-control consumers. + +### Extension Points + +- Query `spp.field.classification` to discover classified/PII fields per model (e.g. `get_fields_requiring_encryption()`) +- Override `spp.classification.pattern.matches_field()` to implement custom detection logic +- Add custom PII categories by extending the `pii_category` selection field + +### Deferred subsystems + +PII-aware enforcement (automatic read-time masking + the `pii=`/`classification=` field attributes), GDPR DSAR handling, data-retention scheduling, and consent integration are part of this module's design but are delivered in a separate governance change. This module ships the classification registry only. + +### Dependencies + +`base`, `spp_security` diff --git a/spp_data_classification/security/ir.model.access.csv b/spp_data_classification/security/ir.model.access.csv new file mode 100644 index 000000000..c49f6d82f --- /dev/null +++ b/spp_data_classification/security/ir.model.access.csv @@ -0,0 +1,9 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_classification_level_admin,Classification Level Admin,model_spp_data_classification_level,group_classification_admin,1,1,1,1 +access_classification_level_manager,Classification Level Manager,model_spp_data_classification_level,group_classification_manager,1,0,0,0 +access_classification_level_user,Classification Level User,model_spp_data_classification_level,base.group_user,1,0,0,0 +access_field_classification_admin,Field Classification Admin,model_spp_field_classification,group_classification_admin,1,1,1,1 +access_field_classification_manager,Field Classification Manager,model_spp_field_classification,group_classification_manager,1,1,1,0 +access_field_classification_user,Field Classification User,model_spp_field_classification,base.group_user,1,0,0,0 +access_classification_pattern_admin,Classification Pattern Admin,model_spp_classification_pattern,group_classification_admin,1,1,1,1 +access_classification_pattern_manager,Classification Pattern Manager,model_spp_classification_pattern,group_classification_manager,1,0,0,0 diff --git a/spp_data_classification/security/security_groups.xml b/spp_data_classification/security/security_groups.xml new file mode 100644 index 000000000..9e4560e24 --- /dev/null +++ b/spp_data_classification/security/security_groups.xml @@ -0,0 +1,69 @@ + + + + + + + Administrator + + 10 + + + + Manager + + 20 + + + + Full Access (Admin) + + 30 + + + + Confidential Access (Officer) + + 40 + + + + + Data Classification Admin + + Full access to data classification configuration. Can create/edit classification levels, patterns, and field classifications. + + + + + + Data Classification Manager + + Can classify fields and view classification settings. Cannot modify classification levels or patterns. + + + + + + PII Full Access + + Can view unmasked PII data across all classification levels. Should be granted sparingly. + + + + + + PII Confidential Access + + Can view unmasked CONFIDENTIAL data. RESTRICTED data remains masked. + + + diff --git a/spp_data_classification/static/description/icon.png b/spp_data_classification/static/description/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..c7dbdaaf1dace8f0ccf8c2087047ddfcf584af0c GIT binary patch literal 15480 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 + + + + +OpenSPP Data Classification + + + +
+

OpenSPP Data Classification

+ + +

Alpha License: LGPL-3 OpenSPP/OpenSPP2

+

Data sensitivity classification registry for OpenSPP. Defines +classification levels, maps model fields to those levels with PII +categorization, and provides regex/CEL auto-detection patterns. This is +the foundation other modules consume to decide what to encrypt, mask, or +audit.

+
+

Key Capabilities

+
    +
  • Define sensitivity levels (PUBLIC, INTERNAL, CONFIDENTIAL, RESTRICTED) +with encryption, masking, audit, consent, and retention policy flags
  • +
  • Map model fields to classification levels with PII categorization +(direct identifiers, quasi-identifiers, biometric, health, financial, +etc.)
  • +
  • Flag classified fields as PII (is_pii, derived from the assigned +PII category) so consumers can query them
  • +
  • Auto-classify fields using regex (or CEL) patterns that match field +names and metadata, via a manually-invoked scan
  • +
+
+
+

Key Models

+ ++++ + + + + + + + + + + + + + + + + +
ModelDescription
spp.data.classification.levelSensitivity levels with +encryption, masking, and +retention policies
spp.field.classificationMaps specific model fields to +classification levels and PII +category
spp.classification.patternAuto-detection patterns using +regex or CEL expressions
+
+
+

Configuration

+

After installing:

+
    +
  1. Navigate to Settings > Data Classification > Classification +Levels to review pre-loaded sensitivity levels (PUBLIC, INTERNAL, +CONFIDENTIAL, RESTRICTED)
  2. +
  3. Review Detection Patterns to customize auto-classification rules +for your organization
  4. +
  5. Map fields under Field Classifications
  6. +
+
+
+

UI Location

+
    +
  • Menu: Settings > Data Classification
  • +
  • Classification Levels: Settings > Data Classification > +Classification Levels
  • +
  • Field Classifications: Settings > Data Classification > Field +Classifications
  • +
  • Detection Patterns: Settings > Data Classification > Detection +Patterns
  • +
+
+
+

Security

+ ++++ + + + + + + + + + + + + + + + + +
GroupAccess
spp_data_classification.group_classification_adminFull CRUD on levels, patterns, +and field classifications
spp_data_classification.group_classification_managerRead/Write/Create field +classifications (no delete); +read-only on levels and patterns
base.group_userRead-only access to +classifications
+

The pii_full_access and pii_confidential_access groups are +defined for downstream masking/access-control consumers.

+
+
+

Extension Points

+
    +
  • Query spp.field.classification to discover classified/PII fields +per model (e.g. get_fields_requiring_encryption())
  • +
  • Override spp.classification.pattern.matches_field() to implement +custom detection logic
  • +
  • Add custom PII categories by extending the pii_category selection +field
  • +
+
+
+

Deferred subsystems

+

PII-aware enforcement (automatic read-time masking + the +pii=/classification= field attributes), GDPR DSAR handling, +data-retention scheduling, and consent integration are part of this +module’s design but are delivered in a separate governance change. This +module ships the classification registry only.

+
+
+

Dependencies

+

base, spp_security

+
+

Important

+

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

+
+

Table of contents

+ +
+

Bug Tracker

+

Bugs are tracked on GitHub Issues. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +feedback.

+

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

+
+
+

Credits

+
+

Authors

+
    +
  • OpenSPP.org
  • +
+
+
+

Maintainers

+

Current maintainers:

+

jeremi gonzalesedwin1123

+

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

+

You are welcome to contribute.

+
+
+
+
+ + diff --git a/spp_data_classification/tests/__init__.py b/spp_data_classification/tests/__init__.py new file mode 100644 index 000000000..fc3a3a6c5 --- /dev/null +++ b/spp_data_classification/tests/__init__.py @@ -0,0 +1,4 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. +from . import test_classification_level +from . import test_field_classification +from . import test_classification_pattern diff --git a/spp_data_classification/tests/test_classification_level.py b/spp_data_classification/tests/test_classification_level.py new file mode 100644 index 000000000..a7a9b661d --- /dev/null +++ b/spp_data_classification/tests/test_classification_level.py @@ -0,0 +1,101 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. +from psycopg2 import IntegrityError + +from odoo.exceptions import UserError, ValidationError +from odoo.tests.common import TransactionCase +from odoo.tools import mute_logger + + +class TestClassificationLevel(TransactionCase): + """Tests for spp.data.classification.level model.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.Level = cls.env["spp.data.classification.level"] + + # Get default levels + cls.level_public = cls.env.ref("spp_data_classification.level_public") + cls.level_internal = cls.env.ref("spp_data_classification.level_internal") + cls.level_confidential = cls.env.ref("spp_data_classification.level_confidential") + cls.level_restricted = cls.env.ref("spp_data_classification.level_restricted") + + def test_default_levels_exist(self): + """Test that default classification levels are created.""" + self.assertTrue(self.level_public) + self.assertTrue(self.level_internal) + self.assertTrue(self.level_confidential) + self.assertTrue(self.level_restricted) + + def test_level_ordering(self): + """Test that levels are ordered by sensitivity (sequence).""" + self.assertLess(self.level_public.sequence, self.level_internal.sequence) + self.assertLess(self.level_internal.sequence, self.level_confidential.sequence) + self.assertLess(self.level_confidential.sequence, self.level_restricted.sequence) + + def test_get_level_by_code(self): + """Test get_level_by_code method.""" + level = self.Level.get_level_by_code("RESTRICTED") + self.assertEqual(level, self.level_restricted) + + level = self.Level.get_level_by_code("NONEXISTENT") + self.assertFalse(level) + + def test_is_more_sensitive_than(self): + """Test sensitivity comparison.""" + self.assertTrue(self.level_restricted.is_more_sensitive_than(self.level_confidential)) + self.assertTrue(self.level_confidential.is_more_sensitive_than(self.level_internal)) + self.assertFalse(self.level_internal.is_more_sensitive_than(self.level_confidential)) + + def test_policy_flags(self): + """Test that policy flags are set correctly.""" + # Restricted requires encryption + self.assertTrue(self.level_restricted.is_requires_encryption) + self.assertTrue(self.level_restricted.is_requires_masking) + self.assertTrue(self.level_restricted.is_requires_audit) + + # Public has minimal requirements + self.assertFalse(self.level_public.is_requires_encryption) + self.assertFalse(self.level_public.is_requires_masking) + self.assertFalse(self.level_public.is_requires_audit) + + @mute_logger("odoo.sql_db") + def test_unique_code_constraint(self): + """Test that classification codes must be unique.""" + with self.assertRaises(Exception) as cm: + self.Level.create( + { + "name": "Duplicate", + "code": "PUBLIC", # Already exists + "sequence": 100, + } + ) + # Should be either ValidationError or IntegrityError + self.assertTrue( + isinstance(cm.exception, (ValidationError, IntegrityError)), + f"Expected ValidationError or IntegrityError, got {type(cm.exception)}", + ) + + def test_cannot_delete_used_level(self): + """Test that levels in use cannot be deleted.""" + # Create a field classification using this level + model = self.env["ir.model"].search([("model", "=", "res.partner")], limit=1) + field = self.env["ir.model.fields"].search( + [ + ("model_id", "=", model.id), + ("name", "=", "name"), + ], + limit=1, + ) + + self.env["spp.field.classification"].create( + { + "model_id": model.id, + "field_id": field.id, + "classification_id": self.level_confidential.id, + } + ) + + # Try to delete - should fail + with self.assertRaises(UserError): + self.level_confidential.unlink() diff --git a/spp_data_classification/tests/test_classification_pattern.py b/spp_data_classification/tests/test_classification_pattern.py new file mode 100644 index 000000000..ec23f73d4 --- /dev/null +++ b/spp_data_classification/tests/test_classification_pattern.py @@ -0,0 +1,109 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. +from odoo.tests.common import TransactionCase + + +class TestClassificationPattern(TransactionCase): + """Tests for spp.classification.pattern model.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.Pattern = cls.env["spp.classification.pattern"] + cls.Classification = cls.env["spp.field.classification"] + + cls.level_restricted = cls.env.ref("spp_data_classification.level_restricted") + cls.level_confidential = cls.env.ref("spp_data_classification.level_confidential") + + def test_pattern_matches_field(self): + """Test pattern matching against field names.""" + pattern = self.Pattern.create( + { + "name": "Test National ID", + "pattern": "(national|passport).*id", + "classification_id": self.level_restricted.id, + "pii_category": "direct_id", + "priority": 90, + } + ) + + self.assertTrue(pattern.matches_field("national_id")) + self.assertTrue(pattern.matches_field("passport_id")) + self.assertTrue(pattern.matches_field("national_identity_id")) + self.assertFalse(pattern.matches_field("name")) + self.assertFalse(pattern.matches_field("email")) + + def test_pattern_with_model_scope(self): + """Test pattern matching with model scope.""" + pattern = self.Pattern.create( + { + "name": "SPP Models Only", + "pattern": ".*_id", + "classification_id": self.level_confidential.id, + "apply_to_model_pattern": r"spp\..*", + "priority": 50, + } + ) + + # Should match SPP models + self.assertTrue(pattern.matches_field("partner_id", "spp.registry.id")) + + # Should not match non-SPP models + self.assertFalse(pattern.matches_field("partner_id", "res.partner")) + + def test_find_matching_pattern(self): + """Test finding best matching pattern.""" + # Create patterns with different priorities + self.Pattern.create( + { + "name": "Low Priority", + "pattern": ".*name", + "classification_id": self.level_confidential.id, + "priority": 10, + } + ) + high_priority = self.Pattern.create( + { + "name": "High Priority - Family Name", + "pattern": "family.*name", + "classification_id": self.level_restricted.id, + "priority": 90, + } + ) + + # family_name should match high priority pattern + result = self.Pattern.find_matching_pattern("family_name") + self.assertEqual(result, high_priority) + + def test_auto_classify_field(self): + """Test auto-classification of a field.""" + self.Pattern.create( + { + "name": "Test Phone", + "pattern": "phone|mobile", + "classification_id": self.level_confidential.id, + "pii_category": "contact", + "default_mask_pattern": "***-***-####", + "default_search_strategy": "partial_index", + "priority": 70, + } + ) + + # Auto-classify phone field on res.partner + classification = self.Pattern.auto_classify_field("res.partner", "phone") + + self.assertTrue(classification) + self.assertEqual(classification.source, "auto") + self.assertEqual(classification.pii_category, "contact") + self.assertEqual(classification.mask_pattern, "***-***-####") + + def test_default_patterns_exist(self): + """Test that default detection patterns are loaded.""" + patterns = self.Pattern.search([]) + self.assertGreater(len(patterns), 0) + + # Check for some expected patterns + national_id = self.Pattern.search([("name", "ilike", "national")]) + self.assertTrue(national_id) + + phone = self.Pattern.search([("name", "ilike", "phone")]) + self.assertTrue(phone) diff --git a/spp_data_classification/tests/test_field_classification.py b/spp_data_classification/tests/test_field_classification.py new file mode 100644 index 000000000..653691840 --- /dev/null +++ b/spp_data_classification/tests/test_field_classification.py @@ -0,0 +1,190 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. +from psycopg2 import IntegrityError + +from odoo.exceptions import ValidationError +from odoo.tests.common import TransactionCase +from odoo.tools import mute_logger + + +class TestFieldClassification(TransactionCase): + """Tests for spp.field.classification model.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.Classification = cls.env["spp.field.classification"] + cls.Level = cls.env["spp.data.classification.level"] + + # Get a model and field to test with + cls.partner_model = cls.env["ir.model"].search([("model", "=", "res.partner")], limit=1) + cls.name_field = cls.env["ir.model.fields"].search( + [ + ("model_id", "=", cls.partner_model.id), + ("name", "=", "name"), + ], + limit=1, + ) + cls.email_field = cls.env["ir.model.fields"].search( + [ + ("model_id", "=", cls.partner_model.id), + ("name", "=", "email"), + ], + limit=1, + ) + + cls.level_confidential = cls.env.ref("spp_data_classification.level_confidential") + cls.level_restricted = cls.env.ref("spp_data_classification.level_restricted") + + def test_create_classification(self): + """Test creating a field classification.""" + classification = self.Classification.create( + { + "model_id": self.partner_model.id, + "field_id": self.name_field.id, + "classification_id": self.level_confidential.id, + "pii_category": "direct_id", + } + ) + + self.assertTrue(classification) + self.assertEqual(classification.model_name, "res.partner") + self.assertEqual(classification.field_name, "name") + + @mute_logger("odoo.sql_db") + def test_unique_constraint(self): + """Test that each field can only have one classification.""" + # Use a different field to avoid conflicts with other tests + phone_field = self.env["ir.model.fields"].search( + [ + ("model_id", "=", self.partner_model.id), + ("name", "=", "phone"), + ], + limit=1, + ) + # Clean up any existing classification for this field + self.Classification.search( + [ + ("model_id", "=", self.partner_model.id), + ("field_id", "=", phone_field.id), + ] + ).unlink() + + self.Classification.create( + { + "model_id": self.partner_model.id, + "field_id": phone_field.id, + "classification_id": self.level_confidential.id, + } + ) + + with self.assertRaises(Exception) as cm: + self.Classification.create( + { + "model_id": self.partner_model.id, + "field_id": phone_field.id, + "classification_id": self.level_restricted.id, + } + ) + # Should be either ValidationError or IntegrityError + self.assertTrue( + isinstance(cm.exception, (ValidationError, IntegrityError)), + f"Expected ValidationError or IntegrityError, got {type(cm.exception)}", + ) + + def test_get_classification(self): + """Test get_classification method.""" + self.Classification.create( + { + "model_id": self.partner_model.id, + "field_id": self.email_field.id, + "classification_id": self.level_confidential.id, + } + ) + + result = self.Classification.get_classification("res.partner", "email") + self.assertTrue(result) + self.assertEqual(result.classification_id, self.level_confidential) + + # Non-existent field + result = self.Classification.get_classification("res.partner", "nonexistent") + self.assertFalse(result) + + def test_get_model_classifications(self): + """Test get_model_classifications method.""" + # Create multiple classifications + self.Classification.create( + { + "model_id": self.partner_model.id, + "field_id": self.name_field.id, + "classification_id": self.level_confidential.id, + } + ) + self.Classification.create( + { + "model_id": self.partner_model.id, + "field_id": self.email_field.id, + "classification_id": self.level_confidential.id, + } + ) + + results = self.Classification.get_model_classifications("res.partner") + self.assertGreaterEqual(len(results), 2) + + def test_ensure_classification(self): + """Test ensure_classification method.""" + # First call creates + result1 = self.Classification.ensure_classification( + "res.partner", + "name", + "CONFIDENTIAL", + source="manual", + ) + self.assertTrue(result1) + + # Second call returns existing + result2 = self.Classification.ensure_classification( + "res.partner", + "name", + "RESTRICTED", # Different level - should be ignored + ) + self.assertEqual(result1, result2) + + def test_get_fields_requiring_encryption(self): + """Test get_fields_requiring_encryption method.""" + self.Classification.create( + { + "model_id": self.partner_model.id, + "field_id": self.name_field.id, + "classification_id": self.level_restricted.id, # Requires encryption + } + ) + + results = self.Classification.get_fields_requiring_encryption("res.partner") + self.assertTrue(any(r.field_name == "name" for r in results)) + + def test_is_pii_computed_and_searchable(self): + """is_pii is True when a PII category is set, False otherwise, and searchable.""" + with_category = self.Classification.create( + { + "model_id": self.partner_model.id, + "field_id": self.name_field.id, + "classification_id": self.level_confidential.id, + "pii_category": "direct_id", + } + ) + without_category = self.Classification.create( + { + "model_id": self.partner_model.id, + "field_id": self.email_field.id, + "classification_id": self.level_confidential.id, + } + ) + + self.assertTrue(with_category.is_pii) + self.assertFalse(without_category.is_pii) + + # Stored + searchable: the [("is_pii", "=", True)] domain (used by the + # PII-encryption wizard and consent integration) must find it. + found = self.Classification.search([("is_pii", "=", True)]) + self.assertIn(with_category, found) + self.assertNotIn(without_category, found) diff --git a/spp_data_classification/views/classification_level_views.xml b/spp_data_classification/views/classification_level_views.xml new file mode 100644 index 000000000..5d36f7cb3 --- /dev/null +++ b/spp_data_classification/views/classification_level_views.xml @@ -0,0 +1,102 @@ + + + + + spp.data.classification.level.list + spp.data.classification.level + + + + + + + + + + + + + + + + + spp.data.classification.level.form + spp.data.classification.level + +
+ +
+ +
+ +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+ + + + Classification Levels + spp.data.classification.level + list,form + +

+ Define data classification levels +

+

+ Classification levels define the sensitivity of data and the policies + that apply to it (encryption, masking, audit, retention). +

+
+
+
diff --git a/spp_data_classification/views/classification_pattern_views.xml b/spp_data_classification/views/classification_pattern_views.xml new file mode 100644 index 000000000..3e3814c14 --- /dev/null +++ b/spp_data_classification/views/classification_pattern_views.xml @@ -0,0 +1,212 @@ + + + + + spp.classification.pattern.list + spp.classification.pattern + + + + + + + + + + + + + + + + + + spp.classification.pattern.form + spp.classification.pattern + +
+
+
+ + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+ + + + spp.classification.pattern.search + spp.classification.pattern + + + + + + + + + + + + + + + + + + + + + + + Detection Patterns + spp.classification.pattern + list,form + + {'search_default_active': 1} + +

+ Define auto-detection patterns +

+

+ Patterns automatically classify fields based on matching rules. + Use Regex for simple name patterns or + CEL for complex rules with field metadata. + Higher priority patterns are evaluated first. +

+
+
+
diff --git a/spp_data_classification/views/field_classification_views.xml b/spp_data_classification/views/field_classification_views.xml new file mode 100644 index 000000000..2093c831c --- /dev/null +++ b/spp_data_classification/views/field_classification_views.xml @@ -0,0 +1,153 @@ + + + + + spp.field.classification.list + spp.field.classification + + + + + + + + + + + + + + + + spp.field.classification.form + spp.field.classification + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ + + + spp.field.classification.search + spp.field.classification + + + + + + + + + + + + + + + + + + + + + + + + + + + + Field Classifications + spp.field.classification + list,form + + +

+ No field classifications yet +

+

+ Field classifications link specific model fields to classification levels. + They can be auto-detected from field names or manually configured. +

+
+
+
diff --git a/spp_data_classification/views/menu.xml b/spp_data_classification/views/menu.xml new file mode 100644 index 000000000..86281cf60 --- /dev/null +++ b/spp_data_classification/views/menu.xml @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + From 7f38e46c8765b45d83ce120b89990a5f0764e350 Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Tue, 25 Aug 2026 12:21:33 +0800 Subject: [PATCH 2/4] fix(spp_data_classification): admin group must not imply base.group_system implied_ids grants the implied groups to members, so the ported link silently promoted anyone holding Data Classification Admin to a full Settings/System administrator (same escalation removed from spp_dci and spp_key_management, and explicitly warned against in spp_pii_encryption). The admin group now implies the manager group instead (keeping the menus, which are gated on manager, visible to admins), and system administrators get explicit ACL rows on the three registry models, mirroring spp_pii_encryption. Regression tests pin the group topology and the admin ACLs. --- spp_data_classification/readme/DESCRIPTION.md | 3 +- .../security/ir.model.access.csv | 3 + .../security/security_groups.xml | 28 +++++--- spp_data_classification/tests/__init__.py | 1 + .../tests/test_security_groups.py | 71 +++++++++++++++++++ 5 files changed, 95 insertions(+), 11 deletions(-) create mode 100644 spp_data_classification/tests/test_security_groups.py diff --git a/spp_data_classification/readme/DESCRIPTION.md b/spp_data_classification/readme/DESCRIPTION.md index 0f03f7d1e..7b1461498 100644 --- a/spp_data_classification/readme/DESCRIPTION.md +++ b/spp_data_classification/readme/DESCRIPTION.md @@ -34,8 +34,9 @@ After installing: | Group | Access | | ------------------------------------------------------ | -------------------------------------------------------- | -| `spp_data_classification.group_classification_admin` | Full CRUD on levels, patterns, and field classifications | +| `spp_data_classification.group_classification_admin` | Full CRUD on levels, patterns, and field classifications; implies Manager | | `spp_data_classification.group_classification_manager` | Read/Write/Create field classifications (no delete); read-only on levels and patterns | +| `base.group_system` | Full CRUD via explicit ACLs. The admin group deliberately does **not** imply system administration (and vice versa) | | `base.group_user` | Read-only access to classifications | The `pii_full_access` and `pii_confidential_access` groups are defined for downstream masking/access-control consumers. diff --git a/spp_data_classification/security/ir.model.access.csv b/spp_data_classification/security/ir.model.access.csv index c49f6d82f..9b75ab250 100644 --- a/spp_data_classification/security/ir.model.access.csv +++ b/spp_data_classification/security/ir.model.access.csv @@ -7,3 +7,6 @@ access_field_classification_manager,Field Classification Manager,model_spp_field access_field_classification_user,Field Classification User,model_spp_field_classification,base.group_user,1,0,0,0 access_classification_pattern_admin,Classification Pattern Admin,model_spp_classification_pattern,group_classification_admin,1,1,1,1 access_classification_pattern_manager,Classification Pattern Manager,model_spp_classification_pattern,group_classification_manager,1,0,0,0 +access_classification_level_system,Classification Level System,model_spp_data_classification_level,base.group_system,1,1,1,1 +access_field_classification_system,Field Classification System,model_spp_field_classification,base.group_system,1,1,1,1 +access_classification_pattern_system,Classification Pattern System,model_spp_classification_pattern,base.group_system,1,1,1,1 diff --git a/spp_data_classification/security/security_groups.xml b/spp_data_classification/security/security_groups.xml index 9e4560e24..54545e393 100644 --- a/spp_data_classification/security/security_groups.xml +++ b/spp_data_classification/security/security_groups.xml @@ -27,16 +27,6 @@ 40 - - - Data Classification Admin - - Full access to data classification configuration. Can create/edit classification levels, patterns, and field classifications. - - - Data Classification Manager @@ -47,6 +37,24 @@ + + + Data Classification Admin + + Full access to data classification configuration. Can create/edit classification levels, patterns, and field classifications. + + + PII Full Access diff --git a/spp_data_classification/tests/__init__.py b/spp_data_classification/tests/__init__.py index fc3a3a6c5..50114194f 100644 --- a/spp_data_classification/tests/__init__.py +++ b/spp_data_classification/tests/__init__.py @@ -2,3 +2,4 @@ from . import test_classification_level from . import test_field_classification from . import test_classification_pattern +from . import test_security_groups diff --git a/spp_data_classification/tests/test_security_groups.py b/spp_data_classification/tests/test_security_groups.py new file mode 100644 index 000000000..0c860b39b --- /dev/null +++ b/spp_data_classification/tests/test_security_groups.py @@ -0,0 +1,71 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. +"""Tests for the data classification security groups.""" + +from odoo import Command +from odoo.tests.common import TransactionCase + + +class TestClassificationSecurityGroups(TransactionCase): + """The classification groups gate the registry menus and ACLs. + + The admin group must NEVER imply base.group_system: implied_ids grants + the implied groups to members, so such a link would turn a scoped + classification role into full Settings/System administration. + """ + + def test_admin_group_does_not_grant_system(self): + """Granting Data Classification Admin must not escalate to system admin.""" + group = self.env.ref("spp_data_classification.group_classification_admin") + system = self.env.ref("base.group_system") + self.assertNotIn(system, group.implied_ids) + # Guard the transitive closure too, so the escalation cannot come + # back through an intermediate group. + self.assertNotIn(system, group.all_implied_ids) + + user = self.env["res.users"].create( + { + "name": "Classification Admin Only", + "login": "classification_admin_only", + "group_ids": [ + Command.link(self.env.ref("base.group_user").id), + Command.link(group.id), + ], + } + ) + self.assertTrue(user.has_group("spp_data_classification.group_classification_admin")) + self.assertFalse( + user.has_group("base.group_system"), + "Data Classification Admin membership must not confer system administration", + ) + + def test_admin_implies_manager(self): + """Admins get the manager role (and thus the menus gated on it).""" + admin = self.env.ref("spp_data_classification.group_classification_admin") + manager = self.env.ref("spp_data_classification.group_classification_manager") + internal = self.env.ref("base.group_user") + self.assertIn(manager, admin.all_implied_ids) + self.assertIn(internal, admin.all_implied_ids) + + def test_admin_group_full_access_on_registry_models(self): + """An admin-only user can administer all three registry models.""" + group = self.env.ref("spp_data_classification.group_classification_admin") + user = self.env["res.users"].create( + { + "name": "Classification Admin ACL", + "login": "classification_admin_acl", + "group_ids": [ + Command.link(self.env.ref("base.group_user").id), + Command.link(group.id), + ], + } + ) + for model in ( + "spp.data.classification.level", + "spp.field.classification", + "spp.classification.pattern", + ): + for operation in ("read", "write", "create", "unlink"): + self.assertTrue( + self.env[model].with_user(user).has_access(operation), + f"Classification admin should have {operation} access on {model}", + ) From b057587d85c010dace9b6d0d0f6bc6e1a21b9978 Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Tue, 25 Aug 2026 12:21:44 +0800 Subject: [PATCH 3/4] fix(spp_data_classification): harden pattern model and close coverage gaps - Validate regexes (pattern, model scope) at save time via api.constrains; previously an uncompilable regex saved fine and just never matched, logging a warning only at scan time. - Catch any evaluation error in _matches_field_cel, not just the narrow tuple: spp.cel.service re-raises whatever the CEL parser/evaluator throws (incl. RecursionError), so one broken pattern could crash an entire registry scan. - Recompute the stored display_name when the level code or the underlying model/field technical name changes (was stale before). - Give model_name an explicit "Model Name" label; the related field inherited ir.model's "Model" string and clashed with model_id's label, making Odoo warn on every registry load. - Cover the CEL degradation/success/error paths, registry scans, the Test Pattern action, and ensure_classification fallbacks with tests (codecov patch was 65.28% vs the 70% gate, with classification_pattern at 45.27%). --- .../models/classification_pattern.py | 26 +- .../models/field_classification.py | 5 +- .../tests/test_classification_level.py | 2 + .../tests/test_classification_pattern.py | 222 ++++++++++++++++++ .../tests/test_field_classification.py | 39 +++ 5 files changed, 289 insertions(+), 5 deletions(-) diff --git a/spp_data_classification/models/classification_pattern.py b/spp_data_classification/models/classification_pattern.py index 8c6f29856..e6c98f23f 100644 --- a/spp_data_classification/models/classification_pattern.py +++ b/spp_data_classification/models/classification_pattern.py @@ -153,6 +153,21 @@ def _check_pattern_or_expression(self): if not record.cel_expression: raise ValidationError(_("CEL expression is required when using CEL match mode")) + @api.constrains("pattern", "apply_to_model_pattern") + def _check_regex_compiles(self): + """Reject uncompilable regexes at save time; at scan time they would + only log a warning and silently never match.""" + for record in self: + for value in (record.pattern, record.apply_to_model_pattern): + if not value: + continue + try: + re.compile(value, re.IGNORECASE) + except re.error as e: + raise ValidationError( + _("Invalid regular expression '%(pattern)s': %(error)s") % {"pattern": value, "error": e} + ) from e + @api.model def _get_compiled_pattern(self, pattern_str): """Get compiled regex pattern with caching. @@ -237,16 +252,19 @@ def _matches_field_cel(self, field_name, model_name=None, field_obj=None, model_ try: result = cel_service.evaluate_expression(self.cel_expression, context) return bool(result) - except (ValueError, TypeError, KeyError, AttributeError) as e: + except SyntaxError as e: _logger.warning( - "CEL evaluation failed for pattern '%s': %s", + "CEL syntax error in pattern '%s': %s", self.name, str(e), ) return False - except SyntaxError as e: + except Exception as e: + # Broad on purpose: evaluate_expression re-raises whatever the CEL + # parser/evaluator throws (incl. RecursionError and service-specific + # errors); one broken pattern must not crash an entire registry scan. _logger.warning( - "CEL syntax error in pattern '%s': %s", + "CEL evaluation failed for pattern '%s': %s", self.name, str(e), ) diff --git a/spp_data_classification/models/field_classification.py b/spp_data_classification/models/field_classification.py index 34151a603..5d6cd0650 100644 --- a/spp_data_classification/models/field_classification.py +++ b/spp_data_classification/models/field_classification.py @@ -36,6 +36,9 @@ class FieldClassification(models.Model): help="The model containing the classified field", ) 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, @@ -176,7 +179,7 @@ class FieldClassification(models.Model): "Each field can only have one classification", ) - @api.depends("model_id", "field_id", "classification_id") + @api.depends("model_id.model", "field_id.name", "classification_id.code") def _compute_display_name(self): for record in self: if record.model_id and record.field_id: diff --git a/spp_data_classification/tests/test_classification_level.py b/spp_data_classification/tests/test_classification_level.py index a7a9b661d..9cc370dfc 100644 --- a/spp_data_classification/tests/test_classification_level.py +++ b/spp_data_classification/tests/test_classification_level.py @@ -46,6 +46,8 @@ def test_is_more_sensitive_than(self): self.assertTrue(self.level_restricted.is_more_sensitive_than(self.level_confidential)) self.assertTrue(self.level_confidential.is_more_sensitive_than(self.level_internal)) self.assertFalse(self.level_internal.is_more_sensitive_than(self.level_confidential)) + # Any level is more sensitive than "no level at all" + self.assertTrue(self.level_public.is_more_sensitive_than(self.Level.browse())) def test_policy_flags(self): """Test that policy flags are set correctly.""" diff --git a/spp_data_classification/tests/test_classification_pattern.py b/spp_data_classification/tests/test_classification_pattern.py index ec23f73d4..9ddeb9a0a 100644 --- a/spp_data_classification/tests/test_classification_pattern.py +++ b/spp_data_classification/tests/test_classification_pattern.py @@ -1,6 +1,31 @@ # Part of OpenSPP. See LICENSE file for full copyright and licensing details. +from unittest.mock import patch + +from odoo.api import Environment +from odoo.exceptions import ValidationError from odoo.tests.common import TransactionCase +_PATTERN_LOGGER = "odoo.addons.spp_data_classification.models.classification_pattern" + + +class _RaisingCelService: + """Stand-in CEL service whose evaluation blows up unexpectedly.""" + + def evaluate_expression(self, expression, context): + raise RuntimeError("boom") + + +class _RecordingCelService: + """Stand-in CEL service that records its call and returns a fixed result.""" + + def __init__(self, result): + self.result = result + self.calls = [] + + def evaluate_expression(self, expression, context): + self.calls.append((expression, context)) + return self.result + class TestClassificationPattern(TransactionCase): """Tests for spp.classification.pattern model.""" @@ -107,3 +132,200 @@ def test_default_patterns_exist(self): phone = self.Pattern.search([("name", "ilike", "phone")]) self.assertTrue(phone) + + def test_pattern_requires_expression_for_mode(self): + """Each match mode requires its own expression field.""" + with self.assertRaises(ValidationError): + self.Pattern.create( + { + "name": "Regex without pattern", + "match_mode": "regex", + "classification_id": self.level_restricted.id, + } + ) + with self.assertRaises(ValidationError): + self.Pattern.create( + { + "name": "CEL without expression", + "match_mode": "cel", + "classification_id": self.level_restricted.id, + } + ) + + def test_invalid_regex_rejected_on_save(self): + """An uncompilable regex must be rejected at save time, not silently + disable matching at scan time.""" + with self.assertRaises(ValidationError): + self.Pattern.create( + { + "name": "Broken regex", + "pattern": "(unclosed", + "classification_id": self.level_restricted.id, + } + ) + with self.assertRaises(ValidationError): + self.Pattern.create( + { + "name": "Broken model scope", + "pattern": "phone", + "apply_to_model_pattern": "(unclosed", + "classification_id": self.level_restricted.id, + } + ) + + def _create_cel_pattern(self): + return self.Pattern.create( + { + "name": "CEL char fields", + "match_mode": "cel", + "cel_expression": "field.type == 'char'", + "classification_id": self.level_confidential.id, + "pii_category": "quasi_id", + "priority": 60, + } + ) + + def test_cel_pattern_degrades_without_service(self): + """Without spp_cel_domain installed, CEL patterns must not match and + must not crash - they log a warning and return False.""" + pattern = self._create_cel_pattern() + # Guard: the CEL service is genuinely absent in this module's test DB + self.assertNotIn("spp.cel.service", self.env) + + with self.assertLogs(_PATTERN_LOGGER, level="WARNING") as capture: + self.assertFalse(pattern.matches_field("phone", "res.partner")) + self.assertIn("CEL service not available", capture.output[0]) + + def test_cel_pattern_matches_via_service(self): + """With a CEL service present, the expression result decides the match + and the service receives the field/model metadata context.""" + pattern = self._create_cel_pattern() + model = self.env["ir.model"].search([("model", "=", "res.partner")], limit=1) + field = self.env["ir.model.fields"].search( + [("model_id", "=", model.id), ("name", "=", "phone")], + limit=1, + ) + + service = _RecordingCelService(result=True) + with patch.object(Environment, "get", return_value=service): + self.assertTrue(pattern.matches_field("phone", "res.partner", field, model)) + + expression, context = service.calls[0] + self.assertEqual(expression, "field.type == 'char'") + self.assertEqual(context["field"].name, "phone") + self.assertEqual(context["field"].type, "char") + self.assertEqual(context["model"].model, "res.partner") + + service_false = _RecordingCelService(result=False) + with patch.object(Environment, "get", return_value=service_false): + self.assertFalse(pattern.matches_field("phone", "res.partner", field, model)) + + def test_cel_evaluation_error_returns_false(self): + """An unexpected evaluation error in one pattern must not crash the + caller (a registry scan) - it logs a warning and reports no match.""" + pattern = self._create_cel_pattern() + with patch.object(Environment, "get", return_value=_RaisingCelService()): + with self.assertLogs(_PATTERN_LOGGER, level="WARNING") as capture: + self.assertFalse(pattern.matches_field("phone", "res.partner")) + self.assertIn("CEL evaluation failed", capture.output[0]) + + def test_build_cel_context(self): + """The CEL context carries field/model metadata, with safe defaults + when the ir.model(.fields) records are not provided.""" + pattern = self._create_cel_pattern() + model = self.env["ir.model"].search([("model", "=", "res.partner")], limit=1) + field = self.env["ir.model.fields"].search( + [("model_id", "=", model.id), ("name", "=", "phone")], + limit=1, + ) + + context = pattern._build_cel_context("phone", "res.partner", field, model) + self.assertEqual(context["field"].name, "phone") + self.assertEqual(context["field"].type, "char") + self.assertTrue(context["field"].store) + self.assertEqual(context["model"].model, "res.partner") + self.assertFalse(context["model"].transient) + + defaults = pattern._build_cel_context("some_field") + self.assertEqual(defaults["field"].name, "some_field") + self.assertEqual(defaults["field"].type, "unknown") + self.assertEqual(defaults["model"].model, "") + + def test_find_matching_pattern_no_match(self): + """No matching pattern yields an empty recordset, and auto-classify + creates nothing.""" + result = self.Pattern.find_matching_pattern("zzz_nothing_matches_this") + self.assertFalse(result) + + classification = self.Pattern.auto_classify_field("res.partner", "zzz_nothing_matches_this") + self.assertFalse(classification) + + def test_scan_model_fields(self): + """Scanning a model classifies matching fields and skips the ones + already classified on the next run.""" + results = self.Pattern.scan_model_fields("res.partner") + matched_fields = [field_name for field_name, _classification in results] + self.assertIn("phone", matched_fields) + + classification = self.Classification.get_classification("res.partner", "phone") + self.assertEqual(classification.source, "auto") + self.assertTrue(classification.pattern_id) + + rescan = self.Pattern.scan_model_fields("res.partner") + self.assertNotIn("phone", [field_name for field_name, _classification in rescan]) + + # skip_classified=False reports already-classified fields again + # (ensure_classification returns the existing record, creates nothing) + full_rescan = self.Pattern.scan_model_fields("res.partner", skip_classified=False) + self.assertIn("phone", [field_name for field_name, _classification in full_rescan]) + self.assertEqual( + self.Classification.search_count([("model_name", "=", "res.partner"), ("field_name", "=", "phone")]), + 1, + ) + + def test_scan_model_fields_unknown_model(self): + """Scanning an unknown model warns and returns an empty list.""" + with self.assertLogs(_PATTERN_LOGGER, level="WARNING") as capture: + self.assertEqual(self.Pattern.scan_model_fields("no.such.model"), []) + self.assertIn("Model not found", capture.output[0]) + + def test_scan_all_models_with_pattern(self): + """The model_pattern filter restricts the scan to matching models.""" + results = self.Pattern.scan_all_models(model_pattern=r"^res\.bank$") + self.assertTrue(results, "res.bank has fields matching the seeded patterns") + self.assertEqual(list(results.keys()), ["res.bank"]) + + def test_action_test_pattern(self): + """The Test Pattern button reports matches against res.partner.""" + pattern = self.Pattern.create( + { + "name": "Test Phone Button", + "pattern": "phone", + "classification_id": self.level_confidential.id, + } + ) + action = pattern.action_test_pattern() + self.assertEqual(action["params"]["type"], "success") + self.assertIn("phone", action["params"]["message"]) + + no_match = self.Pattern.create( + { + "name": "Never Matches", + "pattern": "zzz_nothing_matches_this", + "classification_id": self.level_confidential.id, + } + ) + action = no_match.action_test_pattern() + self.assertEqual(action["params"]["type"], "warning") + + # More than 20 matches truncates the field list in the message + match_all = self.Pattern.create( + { + "name": "Matches Everything", + "pattern": ".", + "classification_id": self.level_confidential.id, + } + ) + action = match_all.action_test_pattern() + self.assertEqual(action["params"]["type"], "success") + self.assertIn("more", action["params"]["message"]) diff --git a/spp_data_classification/tests/test_field_classification.py b/spp_data_classification/tests/test_field_classification.py index 653691840..824db5834 100644 --- a/spp_data_classification/tests/test_field_classification.py +++ b/spp_data_classification/tests/test_field_classification.py @@ -188,3 +188,42 @@ def test_is_pii_computed_and_searchable(self): found = self.Classification.search([("is_pii", "=", True)]) self.assertIn(with_category, found) self.assertNotIn(without_category, found) + + def test_display_name_tracks_level_code(self): + """The stored display name must recompute when the level's code + (or the underlying field name) changes.""" + classification = self.Classification.create( + { + "model_id": self.partner_model.id, + "field_id": self.name_field.id, + "classification_id": self.level_confidential.id, + } + ) + self.assertEqual(classification.display_name, "res.partner.name [CONFIDENTIAL]") + + self.level_confidential.code = "CONF-RENAMED" + self.assertEqual(classification.display_name, "res.partner.name [CONF-RENAMED]") + + # The placeholder branch for records without model/field + empty = self.Classification.new({}) + self.assertEqual(empty.display_name, "New Classification") + + @mute_logger("odoo.addons.spp_data_classification.models.field_classification") + def test_ensure_classification_missing_targets(self): + """ensure_classification returns an empty recordset (and does not + raise) when the model, field, or level cannot be resolved.""" + self.assertFalse(self.Classification.ensure_classification("no.such.model", "name", "CONFIDENTIAL")) + self.assertFalse(self.Classification.ensure_classification("res.partner", "no_such_field", "CONFIDENTIAL")) + self.assertFalse(self.Classification.ensure_classification("res.partner", "ref", "NO_SUCH_LEVEL")) + + def test_field_must_belong_to_model(self): + """A classification pointing a field at the wrong model is rejected.""" + users_model = self.env["ir.model"].search([("model", "=", "res.users")], limit=1) + with self.assertRaises(ValidationError): + self.Classification.create( + { + "model_id": users_model.id, + "field_id": self.name_field.id, # belongs to res.partner + "classification_id": self.level_confidential.id, + } + ) From 0cd8ad3a47f8cbdd35f3bf4346f2067e9fab269a Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Tue, 25 Aug 2026 12:30:22 +0800 Subject: [PATCH 4/4] docs(spp_data_classification): regenerate README from updated fragments Applied verbatim from the CI pre-commit run (32808690341); the pinned CI generator is the sole authority for generated files. --- spp_data_classification/README.rst | 8 +++++++- spp_data_classification/static/description/index.html | 9 ++++++++- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/spp_data_classification/README.rst b/spp_data_classification/README.rst index 07110f3ac..aa5ceb0d6 100644 --- a/spp_data_classification/README.rst +++ b/spp_data_classification/README.rst @@ -89,12 +89,18 @@ Security | Group | Access | +==========================================================+==================================+ | ``spp_data_classification.group_classification_admin`` | Full CRUD on levels, patterns, | -| | and field classifications | +| | and field classifications; | +| | implies Manager | +----------------------------------------------------------+----------------------------------+ | ``spp_data_classification.group_classification_manager`` | Read/Write/Create field | | | classifications (no delete); | | | read-only on levels and patterns | +----------------------------------------------------------+----------------------------------+ +| ``base.group_system`` | Full CRUD via explicit ACLs. The | +| | admin group deliberately does | +| | **not** imply system | +| | administration (and vice versa) | ++----------------------------------------------------------+----------------------------------+ | ``base.group_user`` | Read-only access to | | | classifications | +----------------------------------------------------------+----------------------------------+ diff --git a/spp_data_classification/static/description/index.html b/spp_data_classification/static/description/index.html index 3990811f9..5b4c4e53d 100644 --- a/spp_data_classification/static/description/index.html +++ b/spp_data_classification/static/description/index.html @@ -458,13 +458,20 @@

Security

spp_data_classification.group_classification_admin Full CRUD on levels, patterns, -and field classifications +and field classifications; +implies Manager spp_data_classification.group_classification_manager Read/Write/Create field classifications (no delete); read-only on levels and patterns +base.group_system +Full CRUD via explicit ACLs. The +admin group deliberately does +not imply system +administration (and vice versa) + base.group_user Read-only access to classifications