Skip to content

feat(spp_pii_encryption): port PII encryption core from openspp-modules - #232

Merged
gonzalesedwin1123 merged 14 commits into
19.0from
migrate-spp-pii-encryption
Aug 25, 2026
Merged

feat(spp_pii_encryption): port PII encryption core from openspp-modules#232
gonzalesedwin1123 merged 14 commits into
19.0from
migrate-spp-pii-encryption

Conversation

@gonzalesedwin1123

@gonzalesedwin1123 gonzalesedwin1123 commented Jun 10, 2026

Copy link
Copy Markdown
Member

What

Migrates the field-level PII encryption module from openspp-modules to OpenSPP2 (Odoo 19). This is PR1 of a planned sequence and ports the runtime encryption core; the bulk-migration wizard is deferred (see below).

Realises ADR-011 (Data Classification) / ADR-012 (PII Encryption Strategy).

What's included

Component Model Purpose
Encrypted field mixin spp.encrypted.field.mixin Transparent AES-256-GCM encryption + HMAC-SHA256 blind indexes (exact / partial / phonetic) for searchable encrypted fields
Field config spp.field.encryption.config UI-based per-field encryption configuration
Audit log spp.pii.audit.log Audit trail of PII field access
Masked widget masked_char (OWL) Masked display with reveal + audit logging

Integrates with spp_key_management's key manager (get_key / get_salt).
Depends on: base, spp_key_management, spp_security. External python: cryptography.

What's intentionally deferred

The bulk-migration wizard (scan / dry-run / migrate / backup / rollback of existing plaintext) is not in this PR. Its scan step depends on spp.field.classification from spp_data_classification, which is not yet migrated to OpenSPP2. Plan:

  • PR2 — migrate spp_data_classification
  • PR3 — re-add the migration wizard to this module

Adaptations from the source module

  • Manifest: categoryOpenSPP/Configuration, website → OpenSPP2, auto_install: False (admin opt-in tool), dependency list trimmed (dropped spp_data_classification, the unused spp_encryption, and the unused spp_registry — it was referenced only in docstrings).
  • Removed the wizard's 4 models, view, ACL rows, and Migration menu entries.
  • Wrapped a ValidationError message in _() for translation.

Fixes applied on review (2026-08-25, after rebase onto current 19.0)

  • security: group_encryption_admin no longer implies base.group_system (the same privilege-escalation pattern removed from spp_dci/spp_key_management in security: batch 1 — DCI/Key admin privilege escalations, OAuth signing keys, GRM rule ACL (#327, #329, #265, #266) #399). It now implies base.group_user only; system admins get explicit ACL rows instead.
  • fix: clearing an encrypted field now also clears its *_index / *_last4 blind indexes — previously the stale HMAC hashes stayed searchable after the PII was removed (Gemini finding). create()/write() now share one _apply_encryption_to_vals() helper.
  • fix: masked_char widget ported to Odoo 19 web APIs — useService("rpc") and useService("user") no longer exist in Odoo 19 (setup() would crash as soon as a view used the widget). Now uses the orm service, user from @web/core/user, and _t for user-facing strings.
  • fix: applyMask no longer reveals the tail of a value shorter than (or equal to) the number of # slots — a short value would otherwise have been displayed in full. The mask output deliberately stays pattern-length (does not leak value length).
  • fix: get_user_access_history uses fields.Datetime.now() (UTC) instead of server-local datetime.now() (Gemini finding).
  • fix (found by the new integration test): the DB-configuration path was completely inert — self.env.get("spp.field.encryption.config") returns an empty recordset, which is falsy, so if FieldConfig: never passed and _get_encrypted_fields() / _get_index_type() never consulted spp.field.encryption.config at all. The UI-based configuration (this module's headline mechanism) could never take effect. Now compares against None. The same bug exists in the source module in openspp-modules (encrypted_field_mixin.py:82,133) — the feature has been inert upstream too.

Not applied: Gemini's "critical" claiming models.Constraint doesn't exist — on Odoo 19 models.Constraint is the correct form (_sql_constraints is the one that's ignored); the module installs and all tests pass.

Second fix round (adversarial staff-engineer review, 2026-08-25)

An adversarial review pass found 8 further must-fixes, all applied:

  • fix: read() override used fields_list= as the parameter name; Odoo 19 core calls read(fields=...) by keyword (e.g. res.users, mail.message), which would TypeError on the first adopter with such an ancestor. Renamed to match core.
  • fix: the module's menu was unreachable after install — it was gated on group_encryption_admin and no user holds that group out of the box. The menu now also admits base.group_system (menu visibility only; no implied_ids).
  • security: log_field_access is RPC-reachable and creates rows with sudo(), so any authenticated user could flood or forge the audit trail with entries pointing at arbitrary model/record/field strings. It now validates that the model and field exist, the record exists, and the caller can read it (check_access("read")). Rate limiting and a retention cron are deferred as follow-ups.
  • fix (silent data loss): the config constraint now rejects size-limited Char fields (Odoo silently truncates values to size; a truncated ciphertext is undecryptable, so the plaintext would be destroyed on the very first write with no error) and translated fields (per-language jsonb storage fragments the ciphertext).
  • security: search_by_blind_index/search_by_partial were public RPC methods — a plaintext-confirmation / last-4-bucketing oracle. Renamed _-private; consumer models wrap them with their own access policy.
  • fix: _encrypt_value no longer swallows AccessError (missing key permission) into a generic error; genuine crypto failures now raise UserError instead of a bare ValueError.
  • fix: the mixin's config lookup is now sudo() (it's a policy table) so portal/public reads of a future host model can't crash on the config ACL; the now-unneeded base.group_user read ACL on the config model is dropped (internal users can no longer enumerate the PII field map).
  • docs: DESCRIPTION.md corrected (dependency list, menu path, audit-scope claim overstated).
  • cleanup: removed the dead _encryption_enabled field — nothing read it, and it would have materialized an unexplained column on every adopting model.

The same review confirmed the earlier fixes and the cross-module analysis (module is additive; installing it cannot break other modules), and produced ~19 design-level findings (ciphertext framing/key rotation, copy() double-encryption, non-read() access paths seeing ciphertext, widget masking being cosmetic, blind-index linkage properties, config-toggle data orphaning, per-read overhead). Those are deliberately deferred: the mixin has no adopter yet, and they gate "first model adopts the mixin" (PR3 territory) rather than this migration. Tracked in #451 (items 1–5 there must land before the first model adopts the mixin).

Verification

  • ./spp t spp_pii_encryptionall tests pass (25 tests: original 7 + 13 added in review round 1 + 5 new for the fixes), module installs
  • ✅ Targeted pre-commit hooks (ruff, ruff-format, eslint, prettier) green on changed files
  • ✅ Verified against Odoo 19 source: no rpc service registered in web (widget fix necessary); user is the exported object from @web/core/user
  • ✅ Cross-module check: nothing on 19.0 references spp_pii_encryption, its models, or the masked_char widget — purely additive, mixin is opt-in

Notes

  • The masked widget is registered but not yet wired into any view (ADR-012 lists widget deployment as pending), so the live reveal→audit flow isn't exercised here. True end-to-end mixin coverage on stored records (decrypt-on-read, blind-index search) lands with the first consumer/applier module (PR3 territory) — the ORM write() path is covered via a config-driven test on the mixin's reflected display_name field.
  • A few pre-existing lint warnings (redundant string= params; one except Exception: pass guarding request-context extraction in audit_log.py) were carried over unchanged from the source module.
  • Pre-existing on 19.0 (not this PR): spp_change_request_v2/static/src/components/review_panel/review_panel.js:27 and spp_dci_compliance/static/src/components/security_warning/security_warning.js:19 still use the removed useService("rpc") — same latent crash, worth its own issue.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces the spp_pii_encryption module for Odoo, which implements field-level AES-256-GCM encryption for PII data, searchable blind indexes, UI-based configuration, and access audit logging. The review feedback highlights several critical and high-severity issues: a crash on module load due to the incorrect use of models.Constraint instead of _sql_constraints, stale blind indexes left in the database when encrypted fields are cleared during creation or modification, timezone inconsistencies from using datetime.now() instead of fields.Datetime.now(), and a misleading masking behavior for short values in the frontend widget.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines +105 to +108
_unique_model_field = models.Constraint(
"UNIQUE(model_id, field_id)",
"Encryption configuration already exists for this model and field!",
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

Odoo does not have a models.Constraint class. Defining a constraint this way will raise an AttributeError on module load, preventing the module from starting or installing. SQL constraints in Odoo must be defined using the _sql_constraints list of tuples.

    _sql_constraints = [
        (
            "unique_model_field",
            "UNIQUE(model_id, field_id)",
            "Encryption configuration already exists for this model and field!",
        )
    ]

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not applied — models.Constraint is the correct form on Odoo 19 (see e.g. spp_registry/models/reg_id.py); _sql_constraints is the one that is ignored there. The module installs and all 25 tests pass with this constraint in place.

Comment on lines +348 to +374
def write(self, vals):
"""Encrypt fields before write."""
encrypted_fields = self._get_encrypted_fields()
if not encrypted_fields:
return super().write(vals)

for field_name in encrypted_fields:
if field_name in vals and vals[field_name]:
plaintext = vals[field_name]

# Encrypt
vals[field_name] = self._encrypt_value(plaintext, field_name)

# Get configured index type
index_type = self._get_index_type(field_name)

# Compute blind indexes if fields exist
index_field = f"{field_name}_index"
if index_field in self._fields:
vals[index_field] = self._compute_blind_index(plaintext, field_name, index_type)

last4_field = f"{field_name}_last4"
if last4_field in self._fields:
# SECURITY: Store hashed partial index, not plaintext
vals[last4_field] = self._compute_blind_index(plaintext, field_name, "partial")

return super().write(vals)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

If an encrypted field is cleared (set to False or ""), the current implementation skips the encryption block entirely and does not update the corresponding blind index fields (_index and _last4). This leaves stale hashes in the database, allowing searches to find records that have actually had their PII cleared. We should explicitly clear the index fields when the main field is cleared.

    def write(self, vals):
        """Encrypt fields before write."""
        encrypted_fields = self._get_encrypted_fields()
        if not encrypted_fields:
            return super().write(vals)

        for field_name in encrypted_fields:
            if field_name in vals:
                plaintext = vals[field_name]
                if plaintext:
                    # Encrypt
                    vals[field_name] = self._encrypt_value(plaintext, field_name)

                    # Get configured index type
                    index_type = self._get_index_type(field_name)

                    # Compute blind indexes if fields exist
                    index_field = f"{field_name}_index"
                    if index_field in self._fields:
                        vals[index_field] = self._compute_blind_index(plaintext, field_name, index_type)

                    last4_field = f"{field_name}_last4"
                    if last4_field in self._fields:
                        # SECURITY: Store hashed partial index, not plaintext
                        vals[last4_field] = self._compute_blind_index(plaintext, field_name, "partial")
                else:
                    # Clear indexes if the field is cleared
                    index_field = f"{field_name}_index"
                    if index_field in self._fields:
                        vals[index_field] = False
                    last4_field = f"{field_name}_last4"
                    if last4_field in self._fields:
                        vals[last4_field] = False

        return super().write(vals)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in e0eb689: create()/write() now share _apply_encryption_to_vals(), which clears *_index/*_last4 when the encrypted field is cleared. Covered by test_apply_encryption_to_vals_clears_stale_indexes.

Comment on lines +318 to +346
@api.model_create_multi
def create(self, vals_list):
"""Encrypt fields before create."""
encrypted_fields = self._get_encrypted_fields()
if not encrypted_fields:
return super().create(vals_list)

for vals in vals_list:
for field_name in encrypted_fields:
if field_name in vals and vals[field_name]:
plaintext = vals[field_name]

# Encrypt
vals[field_name] = self._encrypt_value(plaintext, field_name)

# Get configured index type
index_type = self._get_index_type(field_name)

# Compute blind indexes if fields exist
index_field = f"{field_name}_index"
if index_field in self._fields:
vals[index_field] = self._compute_blind_index(plaintext, field_name, index_type)

last4_field = f"{field_name}_last4"
if last4_field in self._fields:
# SECURITY: Store hashed partial index, not plaintext
vals[last4_field] = self._compute_blind_index(plaintext, field_name, "partial")

return super().create(vals_list)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

For consistency and safety, we should also handle the clearing of index fields during record creation if the field is explicitly passed as falsy or empty.

    @api.model_create_multi
    def create(self, vals_list):
        """Encrypt fields before create."""
        encrypted_fields = self._get_encrypted_fields()
        if not encrypted_fields:
            return super().create(vals_list)

        for vals in vals_list:
            for field_name in encrypted_fields:
                if field_name in vals:
                    plaintext = vals[field_name]
                    if plaintext:
                        # Encrypt
                        vals[field_name] = self._encrypt_value(plaintext, field_name)

                        # Get configured index type
                        index_type = self._get_index_type(field_name)

                        # Compute blind indexes if fields exist
                        index_field = f"{field_name}_index"
                        if index_field in self._fields:
                            vals[index_field] = self._compute_blind_index(plaintext, field_name, index_type)

                        last4_field = f"{field_name}_last4"
                        if last4_field in self._fields:
                            # SECURITY: Store hashed partial index, not plaintext
                            vals[last4_field] = self._compute_blind_index(plaintext, field_name, "partial")
                    else:
                        index_field = f"{field_name}_index"
                        if index_field in self._fields:
                            vals[index_field] = False
                        last4_field = f"{field_name}_last4"
                        if last4_field in self._fields:
                            vals[last4_field] = False

        return super().create(vals_list)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in e0eb689 — same shared helper handles create and write uniformly, including explicit falsy values on create.

Comment thread spp_pii_encryption/models/audit_log.py Outdated
Comment on lines +151 to +156
from datetime import datetime, timedelta

if user_id is None:
user_id = self.env.user.id

cutoff = datetime.now() - timedelta(days=days)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Using datetime.now() retrieves the server's local time, which can lead to timezone inconsistencies and incorrect query results because Odoo stores datetime values in UTC in the database. Instead, use fields.Datetime.now() to get the current UTC datetime.

Suggested change
from datetime import datetime, timedelta
if user_id is None:
user_id = self.env.user.id
cutoff = datetime.now() - timedelta(days=days)
from datetime import timedelta
if user_id is None:
user_id = self.env.user.id
cutoff = fields.Datetime.now() - timedelta(days=days)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in b2ab590: uses fields.Datetime.now() (UTC) and passes the datetime directly to the domain. Window behaviour covered by test_get_user_access_history_window_excludes_old_entries.

Comment on lines +64 to +101
applyMask(value, pattern) {
if (!value || !pattern) return "****";

// Pattern interpretation:
// * = mask this character
// # = show this character from the end
// Other chars = literal

const valueChars = value.split("");
const patternChars = pattern.split("");
let result = "";
let valueIdx = 0;
const endIdx = value.length - 1;

// Count # in pattern to know how many chars to show from end
const hashCount = patternChars.filter((c) => c === "#").length;
let showFromEnd = hashCount;

for (const patternChar of patternChars) {
if (patternChar === "*") {
// Mask character
result += "•";
valueIdx++;
} else if (patternChar === "#") {
// Show character from end
const showIdx = value.length - showFromEnd;
if (showIdx >= 0 && showIdx < value.length) {
result += value[showIdx];
}
showFromEnd--;
} else {
// Literal character (like - or space)
result += patternChar;
}
}

return result || "••••••••";
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The current applyMask implementation pads the output with fake bullets to match the full pattern length even if the actual value is much shorter (e.g., a 3-character value like "123" gets masked as "••••-••••-123"). This is highly misleading as it makes short values look long. Additionally, valueIdx is incremented but never used. We should align the value with the pattern from right to left to respect the actual length of the value.

    applyMask(value, pattern) {
        if (!value || !pattern) return "****";

        const valueChars = value.split("");
        const patternChars = pattern.split("");
        const resultChars = [];
        let valIdx = valueChars.length - 1;

        for (let i = patternChars.length - 1; i >= 0; i--) {
            if (valIdx < 0) break;

            const pChar = patternChars[i];
            if (pChar === "#") {
                resultChars.unshift(valueChars[valIdx]);
                valIdx--;
            } else if (pChar === "*") {
                resultChars.unshift("•");
                valIdx--;
            } else {
                resultChars.unshift(pChar);
                if (valueChars[valIdx] === pChar) {
                    valIdx--;
                }
            }
        }

        while (valIdx >= 0) {
            resultChars.unshift("•");
            valIdx--;
        }

        return resultChars.join("") || "••••••••";
    }

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Partially applied in 92fce95. The pattern-length (rather than value-length) mask is deliberate: it avoids leaking how long the real value is, which matters for PII. The real leak here was short values: a value with length <= the number of # slots was displayed in full. That is fixed — such values are now fully masked. The unused valueIdx no longer exists in the current implementation.

@codecov

codecov Bot commented Jun 10, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 95.81882% with 12 lines in your changes missing coverage. Please review.
✅ Project coverage is 75.95%. Comparing base (92a3c20) to head (ad1fd01).
⚠️ Report is 2 commits behind head on 19.0.

Files with missing lines Patch % Lines
spp_pii_encryption/models/audit_log.py 90.56% 5 Missing ⚠️
spp_pii_encryption/models/encrypted_field_mixin.py 97.51% 4 Missing ⚠️
...p_pii_encryption/models/field_encryption_config.py 95.65% 3 Missing ⚠️
Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             19.0     #232      +/-   ##
==========================================
+ Coverage   75.76%   75.95%   +0.18%     
==========================================
  Files         546      552       +6     
  Lines       36576    36906     +330     
==========================================
+ Hits        27713    28031     +318     
- Misses       8863     8875      +12     
Flag Coverage Δ
spp_base_common 91.07% <ø> (ø)
spp_pii_encryption 95.81% <95.81%> (?)
spp_programs 65.53% <ø> (ø)
spp_registry 87.79% <ø> (+0.64%) ⬆️
spp_security 69.56% <ø> (ø)

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
spp_pii_encryption/__init__.py 100.00% <100.00%> (ø)
spp_pii_encryption/models/__init__.py 100.00% <100.00%> (ø)
...p_pii_encryption/models/field_encryption_config.py 95.65% <95.65%> (ø)
spp_pii_encryption/models/encrypted_field_mixin.py 97.51% <97.51%> (ø)
spp_pii_encryption/models/audit_log.py 90.56% <90.56%> (ø)

... and 4 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Migrate the field-level PII encryption module to OpenSPP2 (Odoo 19):

- spp.encrypted.field.mixin: transparent AES-256-GCM encryption with
  HMAC-SHA256 blind indexes (exact/partial/phonetic) for searchability
- spp.field.encryption.config: UI-based per-field encryption config
- spp.pii.audit.log: audit trail of PII field access
- masked_char OWL widget for masked display with reveal + audit

Integrates with spp_key_management's key manager (get_key/get_salt).
Depends on base, spp_key_management, spp_registry, spp_security.

The bulk-migration wizard is intentionally deferred to a follow-up PR:
it depends on spp_data_classification, which is not yet migrated to
OpenSPP2. Realises ADR-011/ADR-012.

Verified: 7/7 mixin tests pass, module installs in full UI stack,
backend asset bundle compiles with the masked widget included.
- masked_field.js: remove unused vars (Component import, valueChars,
  endIdx, valueIdx, unused catch binding) flagged by eslint no-unused-vars
- regenerate README.rst / index.html to match the manifest maintainers
  (oca-gen-addon-readme was failing on stale maintainer block)
…lpers

Raise patch coverage on the migrated module:
- test_field_encryption_config: get_encrypted_fields/get_field_config/
  is_field_encrypted/get_index_type, index-field-name computation (index vs
  last4), toggle actions, and the char/text-only constraint
- test_audit_log: log_field_access, get_access_history,
  get_user_access_history, display_name
- test_encrypted_field_mixin: _get_encrypted_fields default, _get_index_type
  default, phonetic/passthrough normalization, graceful decrypt failure,
  and search helpers when no index column exists

Note: the mixin's create/write/read ORM hooks can't be unit-tested in
isolation (a tests/-defined concrete model isn't registered in this harness,
same limitation spp_approval skips around). They are exercised by consumer
modules that apply the mixin to real PII fields.
…ind indexes

Two mixin fixes found in review + rigid testing:

- env.get() returns an empty (falsy) recordset, so 'if FieldConfig:' never
  passed and the spp.field.encryption.config table was never consulted:
  the UI-based configuration was completely inert (bug also present in the
  openspp-modules source module). Compare against None instead.
- Clearing an encrypted field left its *_index/*_last4 blind indexes in
  place, so the stale HMAC hashes kept matching searches after the PII was
  removed. create()/write() now share _apply_encryption_to_vals(), which
  clears the indexes when the value is cleared.

Adds helper unit tests and a config-driven write() integration test.
create_date is stored in UTC; datetime.now() is server-local, skewing the
get_user_access_history day window on non-UTC servers. Use
fields.Datetime.now() and pass the datetime directly to the domain.
…ryption admin group

implied_ids GRANTS the implied group, so linking base.group_system would
silently promote any Encryption Administrator to a full Settings/System
admin - the same pattern removed from spp_dci and spp_key_management in #399.
Imply base.group_user only; system admins get an explicit ACL row on the
config model instead.
useService('rpc') and useService('user') no longer exist in Odoo 19, so
setup() would crash as soon as a view used the widget. Use the orm service
for the audit call, the user object from @web/core/user for the group
check, and _t() for user-facing strings. Also stop revealing the tail of a
value shorter than or equal to the number of '#' slots - the whole value
would have been displayed; the mask stays pattern-length by design so it
does not leak value length.
spp_registry was referenced only in docstrings; the module is a library
layer over base, spp_key_management and spp_security.
…lure paths

Codecov flagged 33 uncovered lines in encrypted_field_mixin.py - the
create()/read() overrides, both blind-index search helpers, the
encryption-failure branch and the Soundex empty guard, all paths that
normally need a concrete inheriting model. Cover them by mocking the
super() ORM boundary (BaseModel.create/read, the mixin's search) around
the real mixin logic, with key/salt records materialized outside the
patch scopes. 32 tests total.
- rename read() param to 'fields' to match the Odoo 19 core signature
  (core callers like res.users use read(fields=...) by keyword)
- sudo() the encryption-config lookups: it is a policy table and portal
  reads of a host model must not crash on its ACL
- make the blind-index search helpers private - as public RPC methods
  they were a plaintext-confirmation / last-4-bucketing oracle
- re-raise AccessError from _encrypt_value (missing key permission is a
  permission problem) and use UserError for genuine crypto failures
- drop the dead _encryption_enabled field: nothing read it and it would
  materialize an unexplained column on every adopting model
The method is RPC-reachable and creates rows with sudo(), so any
authenticated user (portal included) could flood or forge the audit
trail with entries pointing at arbitrary model/record/field strings.
Now the model and field must exist, the record must exist, and the
caller must be able to read it. Rate limiting and retention are
follow-ups.
… config

Odoo silently truncates Char values to their size limit; a truncated
ciphertext can never be decrypted, so encrypting such a field destroys
the plaintext on the first write with no error. Translated fields store
per-language jsonb values and would fragment the ciphertext.
… docs

- admit base.group_system to the PII Encryption menu: nobody holds
  group_encryption_admin on a fresh install, so the UI was unreachable
  (menu visibility only - no implied_ids escalation)
- drop the base.group_user read ACL on the config model: internal users
  could enumerate the PII field map, and the mixin no longer needs it
  since its config lookup is sudo'd
- DESCRIPTION.md: correct the dependency list, menu paths, audit-scope
  claim, and document the private search helpers
Applied verbatim from the pre-commit run's --show-diff-on-failure output
(run 32802395229); the local generator is not byte-reproducible against
CI's pinned hook env.
@gonzalesedwin1123
gonzalesedwin1123 marked this pull request as ready for review August 25, 2026 03:02
@gonzalesedwin1123
gonzalesedwin1123 merged commit 35021ce into 19.0 Aug 25, 2026
20 checks passed
@gonzalesedwin1123
gonzalesedwin1123 deleted the migrate-spp-pii-encryption branch August 25, 2026 03:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant