From c15982720ce8fdfe2ded5885d0d7c2fd27a5d049 Mon Sep 17 00:00:00 2001 From: servicememeai Date: Sun, 25 Oct 2026 14:38:36 +0900 Subject: [PATCH 01/16] fix: move directory sync off permission hot paths and use fresh write ACL reads --- cloudfile_ext/apps.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/cloudfile_ext/apps.py b/cloudfile_ext/apps.py index e25e2619528..0ee6525f0fe 100644 --- a/cloudfile_ext/apps.py +++ b/cloudfile_ext/apps.py @@ -28,6 +28,7 @@ def ready(self): module.register(registry) self._register_native_acl_gate(registry) + self._register_directory_catalog(registry) registry.seal() @@ -49,6 +50,23 @@ def _register_native_acl_gate(registry): from cloudfile_ext.v2 import native_gate registry.register_permission_check(native_gate.check_permission) + @staticmethod + def _register_directory_catalog(registry): + """Sync enterprise org/role catalog on the existing worker, never on ACL reads. + + The login callback projects an authenticated existing user's memberships; + a missing group or user in an ordinary file request does not create it. + """ + from django.conf import settings + + if not getattr(settings, 'CF_DIRECTORY_URL', ''): + return + interval = getattr(settings, 'CF_DIRECTORY_SYNC_INTERVAL_SECONDS', 600) + if type(interval) is not int or not 60 <= interval <= 86400: + raise ValueError('CF_DIRECTORY_SYNC_INTERVAL_SECONDS must be 60..86400') + from cloudfile_ext.v2.directory_adapter import sync_entity_catalog + registry.register_periodic_task('directory-entities', interval, sync_entity_catalog) + def _configure_database(self): """Add the seafile-db connection and the cf_* router. From c87060d902b7b2975304ff169fce6834c51f3e5e Mon Sep 17 00:00:00 2001 From: servicememeai Date: Sun, 25 Oct 2026 14:38:40 +0900 Subject: [PATCH 02/16] fix: move directory sync off permission hot paths and use fresh write ACL reads --- cloudfile_ext/v2/acl_runtime.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/cloudfile_ext/v2/acl_runtime.py b/cloudfile_ext/v2/acl_runtime.py index 6f5806944b7..266b2119e27 100644 --- a/cloudfile_ext/v2/acl_runtime.py +++ b/cloudfile_ext/v2/acl_runtime.py @@ -107,7 +107,10 @@ def current_principal(username): # A configured Directory owns *all* other external UIDs, including strings # with letters. Missing subjects and expired memberships fail closed; never # treat a non-numeric UID as a native user with broader CE access. - import_string(ensure)(uid, username) + if ensure is not None: + # Explicit custom adapters must be read-only; builtin CE projection is + # populated on login and never refreshed on an authorization request. + import_string(ensure)(uid, username) schema = identifier(SeafileDB().db_name) accounts = identifier(get_ccnet_db_name()) with connection.cursor() as cursor: @@ -121,7 +124,7 @@ def current_principal(username): class Authority: - def __init__(self, username, *, principal=None, rules=None, api=None): + def __init__(self, username, *, principal=None, rules=None, api=None, cache_rules=True): self.username = username self.principal = current_principal(username) if principal is None else principal self.store = store() if rules is None else rules @@ -129,6 +132,7 @@ def __init__(self, username, *, principal=None, rules=None, api=None): from seaserv import seafile_api api = seafile_api self.api = api + self.cache_rules = cache_rules self._repos = {} self._virtual = {} self._versions = {} @@ -139,7 +143,7 @@ def __init__(self, username, *, principal=None, rules=None, api=None): def _rules(self, repo_id, directory): if repo_id not in self._library: - rows = library_rules(self.store, repo_id, cache_ttl()) + rows = library_rules(self.store, repo_id, cache_ttl() if self.cache_rules else 0) # One shared library snapshot for both read and write checks. # Per-request path buckets avoid rescanning every rule per child # of a large directory page; these are NOT another Redis cache. From 95322c6f5027fab05dfb4062cebe9d20fc683842 Mon Sep 17 00:00:00 2001 From: servicememeai Date: Sun, 25 Oct 2026 14:38:42 +0900 Subject: [PATCH 03/16] fix: move directory sync off permission hot paths and use fresh write ACL reads --- cloudfile_ext/v2/directory_adapter.py | 19 ++++++------------- 1 file changed, 6 insertions(+), 13 deletions(-) diff --git a/cloudfile_ext/v2/directory_adapter.py b/cloudfile_ext/v2/directory_adapter.py index 20e83cb4b80..c93012c47c1 100644 --- a/cloudfile_ext/v2/directory_adapter.py +++ b/cloudfile_ext/v2/directory_adapter.py @@ -229,22 +229,15 @@ def subject_ready(uid, username): def ensure_subject(uid, username): + """Login-only refresh for an already authenticated/created native account. + + Never create missing groups as a side effect of user login or ACL lookup. + The periodic catalog synchronizer owns organization/role creation; a + missing mapped entity refuses membership projection until the next sync. + """ if subject_ready(uid, username): return with runtime() as (directory, identity, catalog, memberships): - # Entity synchronization never expands users. Catalog is consulted only - # after the subject TTL expires, including newly assigned departments. - def fetch(current_uid): - subject = directory.subject(current_uid) - _, desired = validate_subject(subject, current_uid) - if desired: - entities = directory.entities() - if not desired <= entities.keys(): - raise IdentityUnavailable('Directory membership entity missing') - for key in sorted(desired): - catalog.ensure(*key, entities[key]) - return subject - memberships.fetch = fetch memberships.ensure(uid, username) From aa6b5e1a39c6c63099e963114e224376bf160e68 Mon Sep 17 00:00:00 2001 From: servicememeai Date: Sun, 25 Oct 2026 14:38:45 +0900 Subject: [PATCH 04/16] fix: move directory sync off permission hot paths and use fresh write ACL reads --- cloudfile_ext/v2/acl_http.py | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/cloudfile_ext/v2/acl_http.py b/cloudfile_ext/v2/acl_http.py index ea913e8d74f..763773ca580 100644 --- a/cloudfile_ext/v2/acl_http.py +++ b/cloudfile_ext/v2/acl_http.py @@ -421,11 +421,18 @@ def post(self, request): checks = data['checks'] if not isinstance(checks, list) or not 1 <= len(checks) <= 4096: raise ValueError() - authority = Authority(data['username']) - permissions = [] + # One authorization authority per call. Writes must read current + # ACL rows, not a cross-request TTL cache; per-request decisions + # still reuse the same library/native permission snapshot. for check in checks: - if set(check) != {'path', 'kind', 'action'} or check['action'] not in ('read', 'write'): + if (not isinstance(check, dict) or set(check) != {'path', 'kind', 'action'} + or check['action'] not in ('read', 'write')): raise ValueError() + only_write = not partial and all(check['action'] == 'write' for check in checks) + authority = Authority(data['username'], cache_rules=not any( + check['action'] == 'write' for check in checks)) + permissions = [] + for check in checks: decision = authority.effective(data['repo_id'], check['path'], check['kind']) if not (decision.can_read if check['action'] == 'read' else decision.can_write): if not partial: @@ -438,8 +445,13 @@ def post(self, request): # when it has no library grant at all, and it needs to know whether # the qualified share root is r or rw: probing again with # action=write would double the round trip for every write. - return response({'allowed': True, 'contract': 'cf-acl-v1', - 'permissions': permissions}) + # Final-commit callers need an all-or-deny verdict, not 4096 + # repeated 'rw' strings. Omit levels for write-only batches so the + # existing 4 KB native HTTP reply limit never rejects a valid batch. + result = {'allowed': True, 'contract': 'cf-acl-v1'} + if not only_write: + result['permissions'] = permissions + return response(result) except Exception as error: return failure(error) From 8ef5745009656a8b2afea0711d7df61ee3c612e7 Mon Sep 17 00:00:00 2001 From: servicememeai Date: Sun, 25 Oct 2026 14:38:48 +0900 Subject: [PATCH 05/16] fix: move directory sync off permission hot paths and use fresh write ACL reads --- cloudfile_ext/v2/subject_adapter.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/cloudfile_ext/v2/subject_adapter.py b/cloudfile_ext/v2/subject_adapter.py index 13752b696b1..b0b86378aaa 100644 --- a/cloudfile_ext/v2/subject_adapter.py +++ b/cloudfile_ext/v2/subject_adapter.py @@ -13,7 +13,8 @@ DEFAULT_PROVIDER = 'external' PREFERRED_PROVIDER_SETTING = 'CF_DIRECTORY_PROVIDER' LEGACY_PROVIDER_SETTING = 'CF_ACL_PROVIDER' -BUILTIN_ENSURE = 'cloudfile_ext.v2.directory_adapter.ensure_subject' +BUILTIN_ENSURE = None # CE Profile + GroupUser are read-only on permission paths +LEGACY_ENSURE = 'cloudfile_ext.v2.directory_adapter.ensure_subject' class InvalidSubjectAdapter(ValueError): @@ -58,7 +59,7 @@ def selected_adapter(config): raise InvalidSubjectAdapter('incomplete enterprise subject adapter') if provider is None: raise InvalidSubjectAdapter('incomplete enterprise subject adapter') - return _checked_provider(provider), ensure + return _checked_provider(provider), None if ensure == LEGACY_ENSURE else ensure # Only the built-in local CE administrator may bypass the configured enterprise From c641b0b5ddf8b934fcc8da80ece04b1eae4df90e Mon Sep 17 00:00:00 2001 From: servicememeai Date: Sun, 25 Oct 2026 14:39:34 +0900 Subject: [PATCH 06/16] test: pin scheduled catalog and read-only authorization identity semantics --- .../v2/tests/test_subject_adapter_pure.py | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/cloudfile_ext/v2/tests/test_subject_adapter_pure.py b/cloudfile_ext/v2/tests/test_subject_adapter_pure.py index a9c1d0fca29..6116525db15 100644 --- a/cloudfile_ext/v2/tests/test_subject_adapter_pure.py +++ b/cloudfile_ext/v2/tests/test_subject_adapter_pure.py @@ -14,25 +14,32 @@ def test_ce_standalone_never_requires_an_external_directory(self): def test_existing_external_url_uses_the_same_directory_callback(self): self.assertEqual(selected_adapter(SimpleNamespace( CF_DIRECTORY_URL='https://directory.example.test')), - ('external', 'cloudfile_ext.v2.directory_adapter.ensure_subject')) + ('external', None)) def test_preferred_provider_setting_names_the_builtin_namespace(self): self.assertEqual(selected_adapter(SimpleNamespace( CF_DIRECTORY_URL='https://directory.example.test', CF_DIRECTORY_PROVIDER='acme')), - ('acme', 'cloudfile_ext.v2.directory_adapter.ensure_subject')) + ('acme', None)) def test_legacy_provider_setting_remains_supported(self): self.assertEqual(selected_adapter(SimpleNamespace( CF_DIRECTORY_URL='https://directory.example.test', CF_ACL_PROVIDER='legacy')), - ('legacy', 'cloudfile_ext.v2.directory_adapter.ensure_subject')) + ('legacy', None)) def test_preferred_provider_wins_when_both_are_set(self): self.assertEqual(selected_adapter(SimpleNamespace( CF_DIRECTORY_URL='https://directory.example.test', CF_DIRECTORY_PROVIDER='acme', CF_ACL_PROVIDER='legacy')), - ('acme', 'cloudfile_ext.v2.directory_adapter.ensure_subject')) + ('acme', None)) + + def test_explicit_legacy_ensure_must_not_sync_during_acl_reads(self): + self.assertEqual(selected_adapter(SimpleNamespace( + CF_DIRECTORY_URL='https://directory.example.test', + CF_ACL_PROVIDER='external', + CF_ACL_SUBJECT_ENSURE='cloudfile_ext.v2.directory_adapter.ensure_subject')), + ('external', None)) def test_custom_enterprise_directory_may_have_no_external_url(self): self.assertEqual(selected_adapter(SimpleNamespace( @@ -69,7 +76,7 @@ def test_namespace_override_keeps_builtin_adapter(self): self.assertEqual(selected_adapter(SimpleNamespace( CF_DIRECTORY_URL='https://directory.example.test', CF_ACL_PROVIDER='acme')), - ('acme', 'cloudfile_ext.v2.directory_adapter.ensure_subject')) + ('acme', None)) def test_half_configured_provider_never_downgrades_to_ce(self): for config in ( From 51c2807a951cac85655dfe9c8b5ee444146c1f84 Mon Sep 17 00:00:00 2001 From: servicememeai Date: Sun, 25 Oct 2026 14:39:37 +0900 Subject: [PATCH 07/16] test: pin scheduled catalog and read-only authorization identity semantics --- .../v2/tests/test_enterprise_subject_adapter.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/cloudfile_ext/v2/tests/test_enterprise_subject_adapter.py b/cloudfile_ext/v2/tests/test_enterprise_subject_adapter.py index cad8e69451e..23d8be4b78a 100644 --- a/cloudfile_ext/v2/tests/test_enterprise_subject_adapter.py +++ b/cloudfile_ext/v2/tests/test_enterprise_subject_adapter.py @@ -16,7 +16,7 @@ def test_standalone_has_no_external_subject_dependency(self): def test_existing_directory_url_keeps_external_defaults(self): self.assertEqual( subject_adapter(SimpleNamespace(CF_DIRECTORY_URL='https://directory.example.test')), - ('external', 'cloudfile_ext.v2.directory_adapter.ensure_subject')) + ('external', None)) def test_explicit_custom_provider_supports_its_own_directory_contract(self): self.assertEqual(subject_adapter(SimpleNamespace( @@ -75,15 +75,15 @@ def test_existing_directory_adapter_is_selected_for_numeric_uid(self): groups=[('org', 'directory', 'dept-1')]) self.assertEqual((result.provider, result.uid), ('external', '10280993')) self.assertIn(('org', 'directory', 'dept-1'), result.groups) - imported.assert_called_once_with('cloudfile_ext.v2.directory_adapter.ensure_subject') - loader.assert_called_once_with('10280993', USER) + imported.assert_not_called() + loader.assert_not_called() connection.assert_called_once() def test_enterprise_alphanumeric_uid_is_not_treated_as_native(self): config = SimpleNamespace(CF_DIRECTORY_URL='https://directory.example.test') result, imported, loader, connection = self.principal('employee-A', config) self.assertEqual((result.provider, result.uid), ('external', 'employee-A')) - loader.assert_called_once_with('employee-A', USER) + loader.assert_not_called() connection.assert_called_once() def test_explicit_custom_provider_works_without_directory_adapter_url(self): @@ -102,7 +102,7 @@ def test_external_business_admin_uses_trusted_directory_not_native(self): result, imported, loader, connection = self.principal( '9', config, username='admin@auth.local') self.assertEqual((result.provider, result.uid), ('external', '9')) - loader.assert_called_once_with('9', 'admin@auth.local') + loader.assert_not_called() connection.assert_called_once() def test_builtin_system_identity_keeps_native_admin_path(self): From a537a97dc5557784caf48203fbf30bb647097803 Mon Sep 17 00:00:00 2001 From: servicememeai Date: Sun, 25 Oct 2026 14:39:40 +0900 Subject: [PATCH 08/16] test: pin scheduled catalog and read-only authorization identity semantics --- .../v2/tests/test_directory_adapter.py | 24 +++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/cloudfile_ext/v2/tests/test_directory_adapter.py b/cloudfile_ext/v2/tests/test_directory_adapter.py index 496f1775fe2..630f4c10581 100644 --- a/cloudfile_ext/v2/tests/test_directory_adapter.py +++ b/cloudfile_ext/v2/tests/test_directory_adapter.py @@ -96,10 +96,11 @@ def test_default_projector_refreshes_only_current_uid_and_proves_entities(self): with patch('cloudfile_ext.v2.directory_adapter.runtime',self.runtime): ensure_subject('uid','opaque@auth.local') self.memberships.ensure.assert_called_once_with('uid','opaque@auth.local') - self.assertEqual(self.memberships.fetch('uid')['userId'],'uid') - self.catalog.ensure.assert_called_once_with('org','directory','o1','Department') - self.directory.entities.side_effect=IdentityUnavailable() - with self.assertRaises(IdentityUnavailable):self.memberships.fetch('uid') + # Only an explicit login refreshes current user membership. Missing + # roles/groups remain unavailable until the scheduled catalog sync. + self.directory.entities.assert_not_called() + self.catalog.ensure.assert_not_called() + self.memberships.fetch.assert_not_called() def test_connection_requires_scoped_service_credential(self): with override_settings(CF_DIRECTORY_URL='https://directory.test/eap/cloudDrive/directory', @@ -129,6 +130,21 @@ def test_directory_request_is_short_lived_scoped_and_does_not_follow_redirect(se with self.assertRaises(IdentityUnavailable):Directory().get('/groups?view=entities') + def test_directory_catalog_is_registered_at_ten_minutes_without_requests(self): + from cloudfile_ext.apps import CloudFileConfig + scheduled = Mock() + with override_settings(CF_DIRECTORY_URL='https://directory.test', + CF_DIRECTORY_SYNC_INTERVAL_SECONDS=600): + CloudFileConfig._register_directory_catalog(scheduled) + scheduled.register_periodic_task.assert_called_once() + name, interval, fn = scheduled.register_periodic_task.call_args.args + self.assertEqual((name, interval), ('directory-entities', 600)) + self.assertEqual(fn.__name__, 'sync_entity_catalog') + scheduled.reset_mock() + with override_settings(CF_DIRECTORY_URL=''): + CloudFileConfig._register_directory_catalog(scheduled) + scheduled.register_periodic_task.assert_not_called() + def test_periodic_entity_sync_updates_only_native_catalog(self): from cloudfile_ext.v2.directory_adapter import sync_entity_catalog def fake_native(group_id): From 811ee9dde67233fc4bfd3ac84737e0a09c7e31c7 Mon Sep 17 00:00:00 2001 From: servicememeai Date: Sun, 25 Oct 2026 14:39:43 +0900 Subject: [PATCH 09/16] test: pin scheduled catalog and read-only authorization identity semantics --- cloudfile_ext/v2/tests/test_acl_http.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/cloudfile_ext/v2/tests/test_acl_http.py b/cloudfile_ext/v2/tests/test_acl_http.py index fcf9b57389c..54e72afd028 100644 --- a/cloudfile_ext/v2/tests/test_acl_http.py +++ b/cloudfile_ext/v2/tests/test_acl_http.py @@ -195,12 +195,23 @@ def test_native_proof_must_be_internal_short_lived_and_current(self): authority.return_value.effective.return_value = Decision('rw','/shared',True) good = dict(is_internal=True,exp=int(time.time())+60) self.assertEqual(self.native(good).status_code,200) - authority.assert_called_once_with('native') + authority.assert_called_once_with('native', cache_rules=False) for bad in (dict(exp=int(time.time())+60),dict(is_internal=True,exp=1),dict(is_internal='true',exp=int(time.time())+60),dict(is_internal=True,exp=int(time.time())+3600)): self.assertEqual(self.native(bad).status_code,403) authority.return_value.effective.return_value = Decision('invisible','/shared',True) self.assertEqual(self.native(good).status_code,403) + def test_write_batch_is_one_fresh_authority_and_small_verdict(self): + claims = dict(is_internal=True, exp=int(time.time()) + 60) + checks = [dict(path='/shared/file', kind='file', action='write')] * 1024 + with patch('cloudfile_ext.v2.acl_http.Authority') as authority: + authority.return_value.effective.return_value = Decision('rw', '/shared', True) + result = self.native(claims, dict(username='native', repo_id=RID, checks=checks)) + self.assertEqual(result.status_code, 200) + self.assertTrue(result.data['allowed']) + self.assertNotIn('permissions', result.data) + authority.assert_called_once_with('native', cache_rules=False) + def test_native_partial_directory_listing_preserves_hidden_children(self): claims = dict(is_internal=True, exp=int(time.time()) + 60) checks = [ From d6f8085b9d7495d22fdc6dd3a266b5c839ddcfaf Mon Sep 17 00:00:00 2001 From: servicememeai Date: Sun, 25 Oct 2026 14:40:29 +0900 Subject: [PATCH 10/16] perf: project only current user's mapped native groups under provider lock --- cloudfile_ext/v2/memberships.py | 63 ++++++++++++++++++++++++--------- 1 file changed, 46 insertions(+), 17 deletions(-) diff --git a/cloudfile_ext/v2/memberships.py b/cloudfile_ext/v2/memberships.py index 21a142930d1..21033d17419 100644 --- a/cloudfile_ext/v2/memberships.py +++ b/cloudfile_ext/v2/memberships.py @@ -120,26 +120,55 @@ def _project(self, uid, username, desired): try: self.identity.active(username, uid, locked=True) with self.connection.cursor() as cursor: - cursor.execute('SELECT subject_type,namespace,external_id,group_id FROM cf_sso_group_map WHERE provider=%s ORDER BY group_id FOR UPDATE', (self.provider,)) - rows = cursor.fetchall() - if len(rows) > 16384: - raise IdentityUnavailable('group catalog too large') - mappings = {(SUBJECT_KIND.get(row[0], row[0]), row[1], row[2]): row[3] for row in rows} - owned = set(mappings.values()) - if len(owned) != len(rows) or not desired <= mappings.keys(): - raise IdentityUnavailable('required native group mapping missing') - # Flat native groups avoid implicit CE hierarchy grants. - # Only scope-approved members are ever projected to GroupUser. - for group in sorted(owned): - cursor.execute('SELECT group_id,parent_group_id FROM ' + self.groups + ' WHERE group_id=%s FOR UPDATE', (group,)) - if cursor.fetchall() != ((group, 0),): - raise IdentityUnavailable('native mapped group changed') - cursor.execute('SELECT group_id,is_staff FROM ' + self.members + ' WHERE user_name=%s ORDER BY group_id FOR UPDATE', (username,)) + # Read just this user's current groups, not every group owned by + # the enterprise Provider. GroupUser rows are the only mutable + # membership facts, and the provider advisory lock prevents the + # scheduled catalog writer from changing mappings mid-refresh. + cursor.execute('SELECT group_id,is_staff FROM ' + self.members + + ' WHERE user_name=%s ORDER BY group_id FOR UPDATE', (username,)) members = cursor.fetchall() + existing = {group for group, _ in members} + if len(existing) != len(members): + raise IdentityUnavailable('duplicate native membership') + owned = set() + if existing: + sql = ('SELECT group_id FROM cf_sso_group_map WHERE provider=%s ' + 'AND group_id IN (' + ','.join(['%s'] * len(existing)) + ')') + cursor.execute(sql, [self.provider, *sorted(existing)]) + rows = cursor.fetchall() + owned = {row[0] for row in rows} + if len(owned) != len(rows): + raise IdentityUnavailable('duplicate provider group mapping') + + required = set() + if desired: + subjects = sorted(desired) + sql = ('SELECT subject_type,namespace,external_id,group_id ' + 'FROM cf_sso_group_map WHERE provider=%s AND ' + '(subject_type,namespace,external_id) IN (' + + ','.join(['(%s,%s,%s)'] * len(subjects)) + ')') + cursor.execute(sql, [self.provider, *(v for key in subjects for v in key)]) + mappings = {(SUBJECT_KIND.get(t, t), ns, ext): gid + for t, ns, ext, gid in cursor.fetchall()} + if len(mappings) != len(subjects) or any(key not in mappings for key in subjects): + # Never attempt on-demand catalog sync or create a user + # from an ACL or login membership lookup. + raise IdentityUnavailable('required native group mapping missing') + required = set(mappings.values()) + if len(required) != len(subjects): + raise IdentityUnavailable('duplicate native group mapping') + + relevant = owned | required + if relevant: + cursor.execute('SELECT group_id,parent_group_id FROM ' + self.groups + + ' WHERE group_id IN (' + ','.join(['%s'] * len(relevant)) + + ') FOR UPDATE', sorted(relevant)) + group_rows = cursor.fetchall() + if (len(group_rows) != len(relevant) or + any(group not in relevant or parent != 0 for group, parent in group_rows)): + raise IdentityUnavailable('native mapped group changed') if any(group in owned and staff != 0 for group, staff in members): raise IdentityConflict('native group administrator requires review') - existing = {group for group, _ in members} - required = {mappings[item] for item in desired} for group in sorted((existing & owned) - required): cursor.execute('DELETE FROM ' + self.members + ' WHERE group_id=%s AND user_name=%s AND is_staff=0', (group, username)) for group in sorted(required - existing): From 8806b5ef2b1d75e24940830b71100a4d7055707f Mon Sep 17 00:00:00 2001 From: servicememeai Date: Sun, 25 Oct 2026 14:40:41 +0900 Subject: [PATCH 11/16] test: exercise indexed per-user group projection without catalog scans --- .../v2/tests/test_direct_groupuser_sql_mock.py | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/cloudfile_ext/v2/tests/test_direct_groupuser_sql_mock.py b/cloudfile_ext/v2/tests/test_direct_groupuser_sql_mock.py index a4e358ffddc..004147ab864 100644 --- a/cloudfile_ext/v2/tests/test_direct_groupuser_sql_mock.py +++ b/cloudfile_ext/v2/tests/test_direct_groupuser_sql_mock.py @@ -40,14 +40,18 @@ def fetchall(self): return self.rows def execute(self, sql, args=()): self.rows = () - if sql.startswith('SELECT subject_type,namespace,external_id,group_id FROM cf_sso_group_map'): - self.rows = (('org', 'directory', 'leaf', 10), - ('org', 'directory', 'parent', 11), - ('role', 'role', 'reviewer', 12)) - elif sql.startswith('SELECT group_id,parent_group_id FROM '): - self.rows = ((args[0], 0),) - elif sql.startswith('SELECT group_id,is_staff FROM '): + maps = {('org', 'directory', 'leaf'): 10, + ('org', 'directory', 'parent'): 11, + ('role', 'role', 'reviewer'): 12} + if sql.startswith('SELECT group_id,is_staff FROM '): self.rows = tuple(sorted(self.conn.members.items())) + elif sql.startswith('SELECT group_id FROM cf_sso_group_map'): + self.rows = tuple((gid,) for gid in maps.values() if gid in args[1:]) + elif sql.startswith('SELECT subject_type,namespace,external_id,group_id FROM cf_sso_group_map'): + needed = {tuple(args[i:i + 3]) for i in range(1, len(args), 3)} + self.rows = tuple((*key, gid) for key, gid in maps.items() if key in needed) + elif sql.startswith('SELECT group_id,parent_group_id FROM '): + self.rows = tuple((gid, 0) for gid in args) elif sql.startswith('DELETE FROM '): if self.conn.fail_delete and args[0] == 11: raise RuntimeError('simulated SQL failure') From 68282f0f61bac179dfffb139d0a5770f07c2b61f Mon Sep 17 00:00:00 2001 From: servicememeai Date: Sun, 25 Oct 2026 14:41:19 +0900 Subject: [PATCH 12/16] perf: reconcile directory catalog by delta on ten-minute worker --- cloudfile_ext/v2/directory_adapter.py | 56 ++++++++++++++++++--------- 1 file changed, 38 insertions(+), 18 deletions(-) diff --git a/cloudfile_ext/v2/directory_adapter.py b/cloudfile_ext/v2/directory_adapter.py index c93012c47c1..c2f7bbecd72 100644 --- a/cloudfile_ext/v2/directory_adapter.py +++ b/cloudfile_ext/v2/directory_adapter.py @@ -242,29 +242,49 @@ def ensure_subject(uid, username): def sync_entity_catalog(): - """PowerJob/manual entity reconciliation, without any user/member writes. + """Scheduled/manual org+role reconciliation; no user or GroupUser writes. - enterprise Directory remains the only organization truth. Preserve deleted/retired CE - groups until a separate reviewed cleanup: they may own libraries. + Read current native mappings once. Unchanged thousands of groups must not + each acquire a provider lock, query CE and call ccnet RPC every ten minutes. + Missing/deleted users are deliberately outside this task. """ from seaserv import ccnet_api with runtime() as (directory, identity, catalog, memberships): - entities = directory.entities() # Already validated /groups?view=entities. + entities = directory.entities() if not entities: raise IdentityUnavailable('empty entity catalog cannot be treated as a successful sync') - renamed = unchanged = 0 + + with catalog.connection.cursor() as cursor: + cursor.execute( + 'SELECT m.subject_type,m.namespace,m.external_id,m.group_id,' + 'g.group_name,g.parent_group_id FROM cf_sso_group_map m ' + 'LEFT JOIN ' + catalog.groups + ' g ON g.group_id=m.group_id ' + 'WHERE m.provider=%s', (catalog.provider,)) + rows = cursor.fetchall() + if len(rows) > 16384: + raise IdentityUnavailable('native group catalog too large') + mapped = {} + for kind, namespace, external_id, gid, label, parent in rows: + key = (kind, namespace, external_id) + if (kind not in ('org', 'role') or key in mapped or + not isinstance(gid, int) or not isinstance(label, str) or parent != 0): + raise IdentityUnavailable('invalid mapped native organization/role') + mapped[key] = (gid, label) + + created = renamed = unchanged = 0 for key, name in sorted(entities.items()): - group_id = catalog.ensure(*key, name) - group = ccnet_api.get_group(group_id) - if group is None: - raise IdentityUnavailable('mapped native group missing') - if group.group_name != name: - ccnet_api.set_group_name(group_id, name) - actual = ccnet_api.get_group(group_id) - if actual is None or actual.group_name != name: - raise IdentityUnavailable('native group rename not confirmed') - renamed += 1 - else: + if key not in mapped: + catalog.ensure(*key, name) + created += 1 + continue + group_id, current_name = mapped[key] + if current_name == name: unchanged += 1 - return dict(status='OK', entities=len(entities), renamed=renamed, - unchanged=unchanged, membership_writes=0) + continue + ccnet_api.set_group_name(group_id, name) + actual = ccnet_api.get_group(group_id) + if actual is None or actual.group_name != name: + raise IdentityUnavailable('native group rename not confirmed') + renamed += 1 + return dict(status='OK', entities=len(entities), created=created, + renamed=renamed, unchanged=unchanged, membership_writes=0) From 27149527da3136b553d0e2dec87c0b1cdc89d589 Mon Sep 17 00:00:00 2001 From: servicememeai Date: Sun, 25 Oct 2026 14:41:31 +0900 Subject: [PATCH 13/16] test: drive scheduled catalog delta with an empty mapped snapshot --- cloudfile_ext/v2/tests/test_directory_adapter.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/cloudfile_ext/v2/tests/test_directory_adapter.py b/cloudfile_ext/v2/tests/test_directory_adapter.py index 630f4c10581..1b2624fa531 100644 --- a/cloudfile_ext/v2/tests/test_directory_adapter.py +++ b/cloudfile_ext/v2/tests/test_directory_adapter.py @@ -27,6 +27,12 @@ def runtime(self): self.directory.entities.return_value={('org','directory','o1'):'Department'} self.identity.prepare.return_value='opaque@auth.local' self.catalog.ensure.return_value=42 + self.catalog.provider = 'external' + self.catalog.groups = 'ccnet.\`Group\`' + reader = self.catalog.connection.cursor.return_value + reader.__enter__ = Mock(return_value=reader) + reader.__exit__ = Mock(return_value=False) + reader.fetchall.return_value = [] yield self.directory, self.identity, self.catalog, self.memberships def test_preparation_proves_directory_uid_and_preserves_native_name(self): From 0f075831d9730bde53a268ef901a273a2c04fc6d Mon Sep 17 00:00:00 2001 From: servicememeai Date: Sun, 25 Oct 2026 14:42:36 +0900 Subject: [PATCH 14/16] docs: describe registered enterprise directory sync worker --- cloudfile_ext/management/commands/cf_worker.py | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/cloudfile_ext/management/commands/cf_worker.py b/cloudfile_ext/management/commands/cf_worker.py index d64e8a92f4b..8f4a14ee755 100644 --- a/cloudfile_ext/management/commands/cf_worker.py +++ b/cloudfile_ext/management/commands/cf_worker.py @@ -6,15 +6,11 @@ the cf-worker container runs this command from the same image as the application. -Registration happens in ``CloudFileConfig.ready()``. The V2 runtime registers -no periodic task today: the SSO/search/external-source modules that used to own -one are retired, and their tables are not part of the V2 schema (SSO directory -sync is the push trigger at -``/api/v2.1/admin/cloudfile/directory/entities/sync/``, and the Meilisearch -indexer is the isolated ``cloudfile_ext.v2.search.worker`` process rather than -a registry task). With no enabled task the command exits 0 immediately, so a -deployment must not restart the container on a clean exit -- only a real -failure is worth retrying. +Registration happens in ``CloudFileConfig.ready()``. Enterprise Directory +org/role catalog synchronization uses this worker at a default 600-second +interval. It never provisions users or GroupUser during permission checks. +Login remains the existing user membership synchronization point. With no +enabled periodic task this command exits 0 immediately. """ import logging From 6a55c3b22443d0a7990fac1449295abbabefbb50 Mon Sep 17 00:00:00 2001 From: servicememeai Date: Sun, 25 Oct 2026 14:42:39 +0900 Subject: [PATCH 15/16] docs: pin directory scheduler login and ACL read-only semantics --- docs/ENTERPRISE-DIRECTORY-CONTRACT.md | 29 +++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/docs/ENTERPRISE-DIRECTORY-CONTRACT.md b/docs/ENTERPRISE-DIRECTORY-CONTRACT.md index 852f54ec947..6065b39ac67 100644 --- a/docs/ENTERPRISE-DIRECTORY-CONTRACT.md +++ b/docs/ENTERPRISE-DIRECTORY-CONTRACT.md @@ -64,3 +64,32 @@ described in #61. This PR does **not** create generic network routing. Aim: a provider-neutral, tiny contract compatible with the existing enterprise Directory adapter, without inventing a framework or adding cf_* tables. + +## Runtime organization/role synchronization (2026-10-25) + +- Configured enterprise Directory: existing `cf_worker` registers + `directory-entities` every `CF_DIRECTORY_SYNC_INTERVAL_SECONDS` seconds, + default 600 (10 minutes, supported 60–86400). The worker must be running. +- Periodic sync reconciles organization/role mappings and names, not users + or individual GroupUser memberships. Unchanged groups are read in one + snapshot rather than each acquiring a lock and native RPC. +- OAuth login refreshes the authenticated existing user's memberships. + Missing role/org mapping refuses the refresh. Neither login nor an + ACL check automatically creates a missing group; scheduled or explicit + directory synchronization owns catalog creation. +- On file permission checks the built-in principal reads only existing + CE User/Profile/GroupUser/cf_sso_group_map facts. No external HTTP + Directory fetch, JIT user provision, group creation or membership mutation. + A missing native user is denied. +- A user's changed membership is refreshed at the next authenticated login + or explicit administrative user-sync event, **not** on each ACL request. + The ten-minute org/role catalog cadence is not a guarantee of ten-minute + active-user membership revocation. +- Internal native **write** permission checks load current ACL rows without + cross-request TTL caching while reusing results within the request; + read/list permission checks keep bounded shared library caching. +- Custom `CF_ACL_SUBJECT_ENSURE` adapters must be read-only. The old + built-in value `cloudfile_ext.v2.directory_adapter.ensure_subject` + remains accepted but no longer triggers synchronization in ACL reads. + +No new CloudFile tables, queue, permission database or scheduler service. From f550056ebdffc58639985dd759b947f90f5bc14e Mon Sep 17 00:00:00 2001 From: servicememeai Date: Sun, 25 Oct 2026 14:50:24 +0900 Subject: [PATCH 16/16] test: prove native writes bypass cross-request ACL snapshots --- cloudfile_ext/v2/tests/test_acl_batch_pure.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cloudfile_ext/v2/tests/test_acl_batch_pure.py b/cloudfile_ext/v2/tests/test_acl_batch_pure.py index bb662aadcd5..a23463be261 100644 --- a/cloudfile_ext/v2/tests/test_acl_batch_pure.py +++ b/cloudfile_ext/v2/tests/test_acl_batch_pure.py @@ -20,7 +20,7 @@ def test_snapshot_order_version_and_provider(self): def test_same_library_rules_for_reads_and_writes(self): source = (Path(__file__).resolve().parents[1] / 'acl_runtime.py').read_text() - self.assertIn('rows = library_rules(self.store, repo_id, cache_ttl())', source) + self.assertIn('rows = library_rules(self.store, repo_id, cache_ttl() if self.cache_rules else 0)', source) self.assertIn('self._rule_paths[repo_id] = buckets', source) self.assertIn('return [rule for path in ancestors(directory)', source) policy = (Path(__file__).resolve().parents[1] / 'acl_http.py').read_text()