You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@allura.apache.org by cc...@apache.org on 2026/07/24 15:02:27 UTC

(allura) 01/03: [#8614] Add backing encrypted fields for User and UserLoginDetails fields

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

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

commit c48e50764e2c946bb47224d91b7e3c0e9b9f658e
Author: Carlos Cruz <ca...@slashdotmedia.com>
AuthorDate: Mon Jul 20 12:19:07 2026 -0600

    [#8614] Add backing encrypted fields for User and UserLoginDetails fields
---
 Allura/allura/model/auth.py            | 48 ++++++++++++++++++-
 Allura/allura/tests/model/test_auth.py | 88 ++++++++++++++++++++++++++++++++++
 2 files changed, 134 insertions(+), 2 deletions(-)

diff --git a/Allura/allura/model/auth.py b/Allura/allura/model/auth.py
index 1ee866005..43c69ac45 100644
--- a/Allura/allura/model/auth.py
+++ b/Allura/allura/model/auth.py
@@ -275,6 +275,15 @@ def before_update(self, obj, state, sess):
         obj.sync_preference_email_address_encrypted()
 
 
+class UserFieldEncryptionMapperExtension(MapperExtension):
+
+    def before_insert(self, obj, state, sess):
+        obj.sync_personal_data_encrypted()
+
+    def before_update(self, obj, state, sess):
+        obj.sync_personal_data_encrypted()
+
+
 class User(MappedClass, ActivityNode, ActivityObject, SearchIndexable):
     SALT_LEN = 8
 
@@ -283,7 +292,7 @@ class __mongometa__:
         session = main_orm_session
         indexes = ['tool_data.AuthPasswordReset.hash']
         unique_indexes = ['username']
-        extensions = [UserPreferencesMapperExtension]
+        extensions = [UserPreferencesMapperExtension, UserFieldEncryptionMapperExtension]
         custom_indexes = [
             dict(fields=('tool_data.phone_verification.number_hash',), sparse=True),
         ]
@@ -342,8 +351,13 @@ class __mongometa__:
         end_date=S.DateTime)])
 
     # Additional contacts
-    socialnetworks = FieldProperty([dict(socialnetwork=str, accounturl=str)])
+    socialnetworks = FieldProperty([dict(
+        socialnetwork=str,
+        accounturl=str,
+        accounturl_encrypted=S.Binary,
+    )])
     telnumbers = FieldProperty([str])
+    telnumbers_encrypted = FieldProperty([S.Binary], if_missing=[])
     webpages = FieldProperty([str])
 
     # Skills list
@@ -357,11 +371,27 @@ class __mongometa__:
     last_access = FieldProperty(dict(
         login_date=S.DateTime,
         login_ip=str,
+        login_ip_encrypted=S.Binary,
         login_ua=str,
         session_date=S.DateTime,
         session_ip=str,
+        session_ip_encrypted=S.Binary,
         session_ua=str))
 
+    def sync_personal_data_encrypted(self):
+        last_access = dict(self.last_access or {})
+        for field_name in ('login_ip', 'session_ip'):
+            value = last_access.get(field_name)
+            last_access[f'{field_name}_encrypted'] = self.encr(value)
+        self.last_access = last_access
+
+        socialnetworks = [dict(socialnetwork) for socialnetwork in self.socialnetworks or []]
+        for socialnetwork in socialnetworks:
+            socialnetwork['accounturl_encrypted'] = self.encr(socialnetwork.get('accounturl'))
+        self.socialnetworks = socialnetworks
+
+        self.telnumbers_encrypted = [self.encr(telnumber) for telnumber in self.telnumbers or []]
+
     def __repr__(self):
         try:
             display_name = self.display_name
@@ -1209,6 +1239,15 @@ def comment_user(cls, by, message, *args, **kwargs):
         return cls.log_user(message, *args, **kwargs)
 
 
+class UserLoginDetailsEncryptionMapperExtension(MapperExtension):
+
+    def before_insert(self, obj, state, sess):
+        obj.sync_ip_encrypted()
+
+    def before_update(self, obj, state, sess):
+        obj.sync_ip_encrypted()
+
+
 class UserLoginDetails(MappedClass):
     """
     Store unique entries for users' previous login details.  And other trusted actions like successful password reset
@@ -1222,15 +1261,20 @@ class __mongometa__:
         indexes = ['user_id']
         unique_indexes = [('user_id', 'ip', 'ua'),  # DuplicateKeyError checked in add_login_detail
                           ]
+        extensions = [UserLoginDetailsEncryptionMapperExtension]
 
     query: Query[UserLoginDetails]
 
     _id = FieldProperty(S.ObjectId)
     user_id: ObjectId = AlluraUserProperty(required=True)
     ip = FieldProperty(str)
+    ip_encrypted = FieldProperty(S.Binary, if_missing=None)
     ua = FieldProperty(str)
     extra = FieldProperty({
         str: S.Anything
     })
 
     user = RelationProperty('User')
+
+    def sync_ip_encrypted(self):
+        self.ip_encrypted = self.encr(self.ip)
diff --git a/Allura/allura/tests/model/test_auth.py b/Allura/allura/tests/model/test_auth.py
index 9ac8a0966..c27ef0b3e 100644
--- a/Allura/allura/tests/model/test_auth.py
+++ b/Allura/allura/tests/model/test_auth.py
@@ -494,6 +494,94 @@ def test_preference_email_address_encrypted_is_synced_on_update(self):
         assert raw_preferences['email_address_encrypted'] == M.User.encr('new@example.com')
         assert M.User.decr(raw_preferences['email_address_encrypted']) == 'new@example.com'
 
+    def test_personal_data_encrypted_fields_are_populated_on_creation(self):
+        user = M.User(
+            username='personal-data-encrypted-create-test',
+            last_access={
+                'login_ip': '192.0.2.1',
+                'session_ip': '198.51.100.1',
+            },
+            socialnetworks=[
+                {'socialnetwork': 'Mastodon', 'accounturl': 'https://example.com/@user'},
+                {'socialnetwork': 'Other', 'accounturl': None},
+            ],
+            telnumbers=['+1-555-0100', None],
+        )
+        ThreadLocalODMSession.flush_all()
+
+        document = state(user).document
+        assert document['last_access']['login_ip'] == '192.0.2.1'
+        assert document['last_access']['login_ip_encrypted'] == M.User.encr('192.0.2.1')
+        assert document['last_access']['session_ip'] == '198.51.100.1'
+        assert document['last_access']['session_ip_encrypted'] == M.User.encr('198.51.100.1')
+        assert document['socialnetworks'][0]['accounturl'] == 'https://example.com/@user'
+        assert document['socialnetworks'][0]['accounturl_encrypted'] == M.User.encr(
+            'https://example.com/@user')
+        assert document['socialnetworks'][1]['accounturl_encrypted'] is None
+        assert document['telnumbers'] == ['+1-555-0100', None]
+        assert user.telnumbers_encrypted == [M.User.encr('+1-555-0100'), None]
+
+    def test_personal_data_encrypted_fields_are_synced_on_update(self):
+        user = M.User(
+            username='personal-data-encrypted-update-test',
+            last_access={
+                'login_ip': '192.0.2.2',
+                'session_ip': '198.51.100.2',
+            },
+            socialnetworks=[
+                {'socialnetwork': 'Mastodon', 'accounturl': 'https://old.example/@user'},
+                {'socialnetwork': 'Other', 'accounturl': 'https://remove.example/user'},
+            ],
+            telnumbers=['+1-555-0101', '+1-555-0102'],
+        )
+        ThreadLocalODMSession.flush_all()
+
+        assert user.telnumbers_encrypted == [
+            M.User.encr('+1-555-0101'),
+            M.User.encr('+1-555-0102'),
+        ]
+        user.last_access['login_ip'] = '203.0.113.2'
+        user.last_access['session_ip'] = None
+        user.socialnetworks[0]['accounturl'] = 'https://new.example/@user'
+        user.socialnetworks.pop()
+        user.telnumbers.remove('+1-555-0101')
+        user.telnumbers.append('+1-555-0103')
+        ThreadLocalODMSession.flush_all()
+
+        document = state(user).document
+        assert document['last_access']['login_ip'] == '203.0.113.2'
+        assert document['last_access']['login_ip_encrypted'] == M.User.encr('203.0.113.2')
+        assert document['last_access']['session_ip'] is None
+        assert document['last_access']['session_ip_encrypted'] is None
+        assert len(document['socialnetworks']) == 1
+        assert document['socialnetworks'][0]['accounturl'] == 'https://new.example/@user'
+        assert document['socialnetworks'][0]['accounturl_encrypted'] == M.User.encr(
+            'https://new.example/@user')
+        assert document['telnumbers'] == ['+1-555-0102', '+1-555-0103']
+        assert user.telnumbers_encrypted == [
+            M.User.encr('+1-555-0102'),
+            M.User.encr('+1-555-0103'),
+        ]
+
+    def test_user_login_details_ip_encrypted_is_synced(self):
+        detail = M.UserLoginDetails(
+            user_id=c.user._id,
+            ip='192.0.2.3',
+            ua='PersonalDataEncryptionTest/1.0',
+        )
+        session(detail).flush(detail)
+
+        assert detail.ip == '192.0.2.3'
+        assert detail.ip_encrypted == M.UserLoginDetails.encr('192.0.2.3')
+        assert state(detail).document['ip'] == '192.0.2.3'
+
+        detail.ip = '203.0.113.3'
+        session(detail).flush(detail)
+
+        assert detail.ip == '203.0.113.3'
+        assert detail.ip_encrypted == M.UserLoginDetails.encr('203.0.113.3')
+        assert state(detail).document['ip'] == '203.0.113.3'
+
     def test_set_display_name_pref_updates_encrypted_field_and_cache(self):
         user = M.User(username='display-name-cache-test',
                       display_name='Original Display Name')