diff --git a/.github/workflows/dev.yml b/.github/workflows/dev.yml index 53a4d90c..0bd74d5d 100644 --- a/.github/workflows/dev.yml +++ b/.github/workflows/dev.yml @@ -44,6 +44,9 @@ jobs: [[ ! -f "$path" ]] || bash -n "$path" done < <(git diff --name-only HEAD^ HEAD -- '*.sh') + - name: Validate one-shot startup admin hook (pure Python) + run: python3 -m unittest discover -s tests -p test_admin_startup_hook.py -q + - name: Enforce current V2 ACL documentation contract run: python3 tests/test_v2_authorization_contract.py diff --git a/deploy/compose/.env.etech-sso.example b/deploy/compose/.env.etech-sso.example index 11e45192..9f332bbc 100644 --- a/deploy/compose/.env.etech-sso.example +++ b/deploy/compose/.env.etech-sso.example @@ -2,3 +2,12 @@ # Generic OAuth defaults stay provider-neutral. No credentials belong in this file. CF_SSO_OAUTH_LOGIN_ID_CLAIM=userId CF_SSO_EAP_PROFILE_IDENTITY=false +# Optional: enable ONLY after the EAP Directory /administrators machine endpoint +# and CE admin ownership have been verified. Uses existing Directory URL/key/secret. +# A failed one-time fetch warns without preventing the CloudFile service startup. +CF_PROVIDER_ADMIN_STARTUP_SYNC_ENABLED=false + +# Etech's business administrator is `admin`, authenticated through the +# trusted Provider and existing SSO. This requires it in the signed roster; +# it never grants privileges just because the username matches. +CF_PROVIDER_ADMIN_EXPECTED_ACCOUNT=admin diff --git a/deploy/compose/.env.example b/deploy/compose/.env.example index 34543146..885e3535 100644 --- a/deploy/compose/.env.example +++ b/deploy/compose/.env.example @@ -344,6 +344,16 @@ CF_SERVICE_SSO_DIRECTORY_SECRET= CF_SERVICE_SSO_DIRECTORY_AUTH_MODE=v2 CF_SERVICE_SSO_DIRECTORY_KEY_ID= CF_SERVICE_SSO_DIRECTORY_SERVICE_ID=cloudfile +# Startup-only administrator roster: no per-request Provider checks. +# Enable after verifying the complete machine-authenticated admin list and +# operator ownership of CE default_admin in the enterprise namespace. +CF_PROVIDER_ADMIN_STARTUP_SYNC_ENABLED=false +CF_PROVIDER_ADMIN_SOURCE= +# Optional exact CE usernames owned by Provider for automatic revocation; empty is safest. +CF_PROVIDER_ADMIN_MANAGED_ACCOUNTS= +# Optional expected trusted admin account; not an elevation by username. +CF_PROVIDER_ADMIN_EXPECTED_ACCOUNT= + # UID-scoped incremental administrative sync: safe preview by default. CF_SSO_UID_DELTA_APPLY_ENABLED=false CF_SSO_UID_DELTA_MAX_REMOVALS=0 diff --git a/deploy/compose/docker-compose.yml b/deploy/compose/docker-compose.yml index 63200be4..da83e896 100644 --- a/deploy/compose/docker-compose.yml +++ b/deploy/compose/docker-compose.yml @@ -301,6 +301,11 @@ services: CF_SSO_DIRECTORY_STATIC: ${CF_SSO_DIRECTORY_STATIC:-} CF_SERVICE_SSO_DIRECTORY_URL: ${CF_SERVICE_SSO_DIRECTORY_URL:-} CF_SERVICE_SSO_DIRECTORY_SECRET: ${CF_SERVICE_SSO_DIRECTORY_SECRET:-} + # One bounded startup-only enterprise admin sync, no periodic/request polling. + CF_PROVIDER_ADMIN_STARTUP_SYNC_ENABLED: ${CF_PROVIDER_ADMIN_STARTUP_SYNC_ENABLED:-false} + CF_PROVIDER_ADMIN_SOURCE: ${CF_PROVIDER_ADMIN_SOURCE:-} + CF_PROVIDER_ADMIN_MANAGED_ACCOUNTS: ${CF_PROVIDER_ADMIN_MANAGED_ACCOUNTS:-} + CF_PROVIDER_ADMIN_EXPECTED_ACCOUNT: ${CF_PROVIDER_ADMIN_EXPECTED_ACCOUNT:-} # Default to v2 on both app and worker; never auto-downgrade failed auth. CF_SERVICE_SSO_DIRECTORY_AUTH_MODE: ${CF_SERVICE_SSO_DIRECTORY_AUTH_MODE:-v2} CF_SERVICE_SSO_DIRECTORY_KEY_ID: ${CF_SERVICE_SSO_DIRECTORY_KEY_ID:-} diff --git a/scripts/scripts_14.0/start.py b/scripts/scripts_14.0/start.py index 6fa80287..457a47cb 100755 --- a/scripts/scripts_14.0/start.py +++ b/scripts/scripts_14.0/start.py @@ -27,6 +27,7 @@ from cloudfile import write_settings as write_cloudfile_settings from cloudfile import write_policy_worker_hooks from cloudfile_schema import prepare_fresh_schema, initialize_fresh_schema, verify_development_schema +from cloudfile_admin_startup import run_provider_admin_sync shared_seafiledir = '/shared/seafile' @@ -112,6 +113,12 @@ def main(): if exists(password_file): os.unlink(password_file) + # One low-frequency sync at service start, never in Gunicorn workers or + # every HTTP request. The Provider can be offline; warn and keep CE running. + run_provider_admin_sync(get_script('seahub.sh'), + join(installdir, 'seahub', 'manage.py'), + non_root=non_root) + print('seafile server is running now.') try: watch_controller() diff --git a/scripts/scripts_cf14.0/cloudfile_admin_startup.py b/scripts/scripts_cf14.0/cloudfile_admin_startup.py new file mode 100644 index 00000000..8f85fa13 --- /dev/null +++ b/scripts/scripts_cf14.0/cloudfile_admin_startup.py @@ -0,0 +1,44 @@ +"""One bounded CloudFile administrator sync after a normal Seafile startup. + +Do not run from Seahub/Gunicorn worker initialization: those launch repeatedly +and must never create another background reconciliation loop. +""" +import os +import shlex +import subprocess + + +def run_provider_admin_sync(seahub_script, manage_script, *, non_root=False, + enabled=None, runner=subprocess.run): + if enabled is None: + enabled = os.getenv('CF_PROVIDER_ADMIN_STARTUP_SYNC_ENABLED', 'false').lower() == 'true' + if enabled is not True: + return 'SKIPPED' + command = [seahub_script, 'python-env', 'python3', manage_script, + 'cf_sync_provider_admins'] + if non_root: + command = ['su', '-p', 'seafile', '-c', shlex.join(command)] + try: + # Only one process per container start. Provider request has its own + # 5s bound; leave some time for CE SQL/RPC. Never block the file service + # if an external enterprise Directory is unavailable. + result = runner(command, capture_output=True, text=True, timeout=30, + check=False) + if result.returncode: + print('CF_ADMIN_SYNC FAILED (nonfatal): command returned %d' + % result.returncode, flush=True) + return 'FAILED' + # A zero exit code is not sufficient: the management command may + # explicitly SKIP because its generated settings were not enabled. + # Never claim successful sync without the command's applied marker. + lines = (result.stdout or '').strip().splitlines() + applied = [line.strip() for line in lines + if line.strip().startswith('CF_ADMIN_SYNC APPLIED added=')] + if len(applied) != 1: + print('CF_ADMIN_SYNC FAILED (nonfatal): no applied receipt', flush=True) + return 'FAILED' + print(applied[0], flush=True) + return 'APPLIED' + except (OSError, subprocess.SubprocessError): + print('CF_ADMIN_SYNC FAILED (nonfatal): command unavailable', flush=True) + return 'FAILED' diff --git a/scripts/scripts_cf14.0/cloudfile_bootstrap.py b/scripts/scripts_cf14.0/cloudfile_bootstrap.py index 2864f0d2..cb8fc24e 100644 --- a/scripts/scripts_cf14.0/cloudfile_bootstrap.py +++ b/scripts/scripts_cf14.0/cloudfile_bootstrap.py @@ -196,6 +196,21 @@ def write_cloudfile_settings(): ) body += _settings_block_sso() + # EAP may use the existing OAuth/SSO bridge with the legacy full + # group-synchronizer disabled. When the new one-shot administrator sync is + # explicitly enabled, still expose its trusted Directory connection. + if (not cf_enabled('CF_ENABLE_SSO') and + get_conf('CF_PROVIDER_ADMIN_STARTUP_SYNC_ENABLED', 'false').lower() == 'true'): + body += 'CF_DIRECTORY_URL = %r\n' % get_conf('CF_SERVICE_SSO_DIRECTORY_URL', '') + body += 'CF_DIRECTORY_KEY_ID = %r\n' % get_conf('CF_SERVICE_SSO_DIRECTORY_KEY_ID', '') + body += 'CF_DIRECTORY_SECRET = %r\n' % get_conf('CF_SERVICE_SSO_DIRECTORY_SECRET', '') + # Low-frequency native admin sync is separate from the old SSO worker. + # It must remain off in Standalone CE and must never start an HTTP poll. + body += 'CF_PROVIDER_ADMIN_STARTUP_SYNC_ENABLED = %r\n' % ( + get_conf('CF_PROVIDER_ADMIN_STARTUP_SYNC_ENABLED', 'false').lower() == 'true') + body += 'CF_PROVIDER_ADMIN_SOURCE = %r\n' % get_conf('CF_PROVIDER_ADMIN_SOURCE', '') + body += 'CF_PROVIDER_ADMIN_MANAGED_ACCOUNTS = %r\n' % get_conf('CF_PROVIDER_ADMIN_MANAGED_ACCOUNTS', '') + body += 'CF_PROVIDER_ADMIN_EXPECTED_ACCOUNT = %r\n' % get_conf('CF_PROVIDER_ADMIN_EXPECTED_ACCOUNT', '') body += _settings_block_search() body += _settings_block_v04_preview_embed() body += _settings_block_external_sources() diff --git a/tests/test_admin_startup_hook.py b/tests/test_admin_startup_hook.py new file mode 100644 index 00000000..c5d09df8 --- /dev/null +++ b/tests/test_admin_startup_hook.py @@ -0,0 +1,71 @@ +"""Cheap startup-hook checks; no Docker image, database or network required.""" +import importlib.util +from pathlib import Path +from types import SimpleNamespace +import subprocess +import unittest +from unittest.mock import Mock + +FILE = (Path(__file__).resolve().parents[1] / 'scripts' / + 'scripts_cf14.0' / 'cloudfile_admin_startup.py') +spec = importlib.util.spec_from_file_location('admin_sync_startup', FILE) +admin_sync = importlib.util.module_from_spec(spec) +spec.loader.exec_module(admin_sync) + + +class StartupSyncHookTests(unittest.TestCase): + def test_disabled_never_spawns_an_process(self): + spawn = Mock(side_effect=AssertionError('disabled')) + self.assertEqual(admin_sync.run_provider_admin_sync( + '/seahub.sh', '/seahub/manage.py', enabled=False, runner=spawn), + 'SKIPPED') + spawn.assert_not_called() + + def test_enabled_spawns_once_with_bounded_timeout(self): + spawn = Mock(return_value=SimpleNamespace( + returncode=0, stdout='CF_ADMIN_SYNC APPLIED added=1 removed=0 unchanged=0\n')) + self.assertEqual(admin_sync.run_provider_admin_sync( + '/seahub.sh', '/seahub/manage.py', enabled=True, runner=spawn), 'APPLIED') + args, kw = spawn.call_args + self.assertEqual(args[0][-2:], ['/seahub/manage.py', 'cf_sync_provider_admins']) + self.assertEqual(kw['timeout'], 30) + self.assertFalse(kw['check']) + self.assertEqual(spawn.call_count, 1) + + def test_failed_provider_never_aborts_container_startup(self): + self.assertEqual(admin_sync.run_provider_admin_sync( + '/seahub.sh', '/seahub/manage.py', enabled=True, + runner=Mock(return_value=SimpleNamespace(returncode=1, stdout=''))), + 'FAILED') + self.assertEqual(admin_sync.run_provider_admin_sync( + '/seahub.sh', '/seahub/manage.py', enabled=True, + runner=Mock(side_effect=subprocess.TimeoutExpired('sync', 30))), + 'FAILED') + + def test_zero_exit_without_applied_receipt_is_failure_not_false_success(self): + for stdout in ('', 'CF_ADMIN_SYNC SKIPPED (not configured)', + 'unrelated successful output'): + with self.subTest(stdout=stdout): + self.assertEqual(admin_sync.run_provider_admin_sync( + '/seahub.sh', '/seahub/manage.py', enabled=True, + runner=Mock(return_value=SimpleNamespace( + returncode=0, stdout=stdout))), 'FAILED') + + def test_etech_default_admin_compatibility_remains_opt_in(self): + root = Path(__file__).resolve().parents[1] + env = (root / 'deploy/compose/.env.etech-sso.example').read_text() + self.assertIn('CF_PROVIDER_ADMIN_EXPECTED_ACCOUNT=admin', env) + self.assertIn('CF_PROVIDER_ADMIN_STARTUP_SYNC_ENABLED=false', env) + + def test_nonroot_command_is_one_safely_quoted_shell_command(self): + spawn = Mock(return_value=SimpleNamespace(returncode=0, stdout='CF_ADMIN_SYNC APPLIED added=1 removed=0 unchanged=0')) + self.assertEqual(admin_sync.run_provider_admin_sync( + '/seahub.sh', '/seahub/manage.py', enabled=True, + non_root=True, runner=spawn), 'APPLIED') + args = spawn.call_args.args[0] + self.assertEqual(args[:4], ['su', '-p', 'seafile', '-c']) + self.assertIn('cf_sync_provider_admins', args[-1]) + + +if __name__ == '__main__': + unittest.main()