You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@allura.apache.org by br...@apache.org on 2026/04/23 18:43:15 UTC

(allura) 01/03: Fix convert plaintext to encrypted script to support nested fields

This is an automated email from the ASF dual-hosted git repository.

asf-gitbox-commits pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/allura.git

commit 72a31893d6a3176b47afe9b421bbddf599070c22
Author: Carlos Cruz <ca...@slashdotmedia.com>
AuthorDate: Wed Apr 15 21:00:59 2026 +0000

    Fix convert plaintext to encrypted script to support nested fields
---
 scripts/convert_encrypted_field.py | 105 +++++++++++++++++++++++++++++--------
 1 file changed, 82 insertions(+), 23 deletions(-)

diff --git a/scripts/convert_encrypted_field.py b/scripts/convert_encrypted_field.py
index 04a5b3bfe..46e2f61ba 100644
--- a/scripts/convert_encrypted_field.py
+++ b/scripts/convert_encrypted_field.py
@@ -21,23 +21,62 @@
 
 import defopt
 
-from allura.lib.utils import chunked_find
 from ming import schema
-from ming.odm import session
+from ming.odm import mapper, session
 from ming.odm.declarative import MappedClass
 from ming.odm.property import FieldProperty, DecryptedProperty
 
 
-def _encrypted_field_name(plain_field_name: str) -> str:
-    path = plain_field_name.split('.')
-    path[-1] = f'{path[-1]}_encrypted'
-    return '.'.join(path)
+CHUNK_SIZE = 1000
 
 
-def _get_field_value(record, field_name: str):
-    value = record
-    for part in field_name.split('.'):
-        value = value[part]
+class MissingFieldPathError(Exception):
+    pass
+
+
+def _default_encrypted_field_name(plain_field_name: str) -> str:
+    parent, sep, leaf = plain_field_name.rpartition('.')
+    return f'{parent}{sep}{leaf}_encrypted'
+
+
+def _split_field_path(field_name: str) -> list[str]:
+    field_path = field_name.split('.')
+    assert field_path and all(field_path), f'Invalid dotted field path: {field_name!r}'
+    return field_path
+
+
+def _schema_for_field_path(Model: type[MappedClass], field_name: str):
+    field_path = _split_field_path(field_name)
+    top_level_name = field_path[0]
+    try:
+        top_level_prop = getattr(Model, top_level_name)
+    except AttributeError as e:
+        raise MissingFieldPathError(
+            f'Missing field path {field_name!r}; no top-level field {top_level_name!r}') from e
+    assert isinstance(top_level_prop, FieldProperty)
+
+    current_schema = top_level_prop.field.schema
+    for i, path_part in enumerate(field_path[1:], start=1):
+        traversed = '.'.join(field_path[:i])
+        if isinstance(current_schema, schema.Array):
+            raise AssertionError(
+                f'Nested array paths are not supported: {field_name!r} (array at {traversed!r})')
+        if not isinstance(current_schema, schema.Object):
+            raise AssertionError(
+                f'Invalid nested field path {field_name!r}; {traversed!r} is not an object field')
+        if path_part not in current_schema.fields:
+            raise MissingFieldPathError(
+                f'Invalid nested field path {field_name!r}; missing key {traversed + "." + path_part!r}')
+
+        current_schema = current_schema.fields[path_part]
+
+    return current_schema
+
+
+def _get_nested_value(rec: dict, field_name: str):
+    value = rec
+    for path_part in _split_field_path(field_name):
+        value = value[path_part]
     return value
 
 
@@ -45,23 +84,31 @@ def main(class_name: str, plain_field_name: str,
          *, remove_unencrypted: bool = False, redo_all: bool = False):
     """
     :param class_name: full class name, e.g. allura.model.user.User
-    :param plain_field_name: name of the unencrypted field, e.g. display_name or author.username
+    :param plain_field_name: name of the unencrypted field, e.g. display_name
     :param remove_unencrypted: WARNING only run this after your codebase is already on the latest code
     :param redo_all: re-encrypt records that already have encrypted values (in case they changed since last run)
     """
-    encrypted_field_name = _encrypted_field_name(plain_field_name)
-    is_nested_field = '.' in plain_field_name
+    encrypted_field_name = _default_encrypted_field_name(plain_field_name)
 
     module_name, class_basename = class_name.rsplit('.', 1)
     module = import_module(module_name)
     Model: type[MappedClass] = getattr(module, class_basename)
 
     # sanity checks that the fields are correct and ready
-    if not is_nested_field:
-        encr_prop = getattr(Model, encrypted_field_name)
-        assert isinstance(encr_prop, FieldProperty)
-        assert encr_prop.field.type == schema.Binary
-        if remove_unencrypted:
+    try:
+        encr_schema = _schema_for_field_path(Model, encrypted_field_name)
+    except MissingFieldPathError:
+        # pre-migration support: encrypted field might not yet be present in
+        # model schema, but we can still create it with raw MongoDB updates
+        encr_schema = None
+    if encr_schema is not None:
+        assert isinstance(encr_schema, schema.Binary)
+    elif remove_unencrypted:
+        raise AssertionError(
+            f'Cannot use --remove-unencrypted because {encrypted_field_name!r} '
+            'is not present in model schema yet')
+    if remove_unencrypted:
+        if '.' not in plain_field_name:
             plain_prop = Model.__dict__[plain_field_name]  # getattr() better but needs Ming fix released
             assert isinstance(plain_prop, DecryptedProperty)
             assert plain_prop.encrypted_field == encrypted_field_name
@@ -90,16 +137,28 @@ def main(class_name: str, plain_field_name: str,
     }
     if not redo_all:
         q[encrypted_field_name] = None
+
+    m = mapper(Model)
+    raw_collection = sess.impl.db[m.collection.m.collection_name]
+
     count = 0
-    for chunk in chunked_find(Model, q):
-        for rec in chunk:
-            val = _get_field_value(rec, plain_field_name)
+    projection = {'_id': 1, plain_field_name: 1}
+    last_id = None
+    while True:
+        chunk_q = {'$and': [q, {'_id': {'$gt': last_id}}]} if last_id is not None else q
+        docs = list(raw_collection.find(chunk_q, projection).sort('_id', 1).limit(CHUNK_SIZE))
+        if not docs:
+            break
+
+        for rec_doc in docs:
+            val = _get_nested_value(rec_doc, plain_field_name)
             encr_val = Model.encr(val)
-            Model.query.update({'_id': rec['_id']}, {
+            raw_collection.update_one({'_id': rec_doc['_id']}, {
                 '$set': {encrypted_field_name: encr_val},
             })
             count += 1
-            sess.expunge(rec)
+
+        last_id = docs[-1]['_id']
         print(f'Converted {count} so far...')
     if not count:
         print(f'Did not find any {class_name} records with {plain_field_name} values to encrypt')