diff --git a/deployment/requests/HLCC-EXACT-REPOSITORY-ACL-PERSIST-20260806.json b/deployment/requests/HLCC-EXACT-REPOSITORY-ACL-PERSIST-20260806.json new file mode 100644 index 0000000..b7a461d --- /dev/null +++ b/deployment/requests/HLCC-EXACT-REPOSITORY-ACL-PERSIST-20260806.json @@ -0,0 +1,64 @@ +{ + "schema": "guanghu.architecture-provision-request/v1", + "request_id": "HLCC-EXACT-REPOSITORY-ACL-PERSIST-20260806", + "target_node": "JD-FD-PRIMARY", + "architecture_id": "GLS-HLCC-009", + "module": { + "code": "HLCC-JD-CANDIDATE-01", + "name": "光湖代码频道精确仓库 ACL 持久化", + "bind": "loopback:3340,3341", + "owner": "systemd", + "unit": "hlcc-jd-candidate.service", + "run_user": "guanghu", + "writable_paths": [ + "/var/lib/guanghu/personas/guanghu/hlcc-v16.0.1" + ], + "read_only_paths": [] + }, + "source_ref": "REPO-012:refs/heads/main", + "deployed_commit_policy": "use the exact human-merged commit and record it in the server-owned deployment receipt", + "source_paths": [ + "server-tools/hololake-code-channel/jd-candidate/hlcc-bootstrap.py", + "server-tools/hololake-code-channel/jd-candidate/app.ini", + "server-tools/hololake-code-channel/jd-candidate/hlcc-jd-candidate.service" + ], + "initial_provision": { + "kind": "new-architecture-unit", + "not_an_existing_action_bridge_extension": true, + "requires": [ + "leave core.sharedRepository unset to avoid setgid operations under RestrictSUIDSGID", + "apply inherited ACLs only to the exact repository objects and refs", + "grant only guanghu-authz the additional repository access", + "preserve UMask=0077 for all other code-channel state", + "keep the obsolete recursive chmod repository hook absent", + "quote the generated Forgejo custom-hook basename test", + "restart only hlcc-jd-candidate.service", + "do not alter repository content, branches, owner identity or authentication" + ] + }, + "verification": [ + "the automatic deployment receipt records DEPLOYED_AND_VERIFIED", + "the candidate subsequently reaches ready=true and mode=isolated-candidate", + "core.sharedRepository remains unset", + "objects and refs inherit the exact guanghu-authz ACL", + "a new-object multilevel branch push and delete complete without warnings", + "both code-channel and direct-receiver repository checks pass" + ], + "runtime_check": { + "url": "http://127.0.0.1:3341/health", + "expected": { + "ok": true, + "mode": "bootstrap", + "version": "16.0.1", + "code": "HLCC-JD-CANDIDATE-01", + "package_profile": "full-offline-v16.0.1" + } + }, + "rollback": [ + "restore the backed-up hlcc-jd-candidate.service", + "restart the previous immutable release", + "retain repository content and authentication state", + "use the server-owned ACL backup for manual permission recovery" + ], + "status": "ARCHITECTURE_PACKAGE_READY · INITIAL_PROVISION_PENDING" +} diff --git a/server-tools/hololake-code-channel/jd-candidate/hlcc-bootstrap.py b/server-tools/hololake-code-channel/jd-candidate/hlcc-bootstrap.py index 3a35229..92d21cf 100644 --- a/server-tools/hololake-code-channel/jd-candidate/hlcc-bootstrap.py +++ b/server-tools/hololake-code-channel/jd-candidate/hlcc-bootstrap.py @@ -18,6 +18,7 @@ import time import urllib.error import urllib.parse import urllib.request +from collections.abc import Callable VERSION = "16.0.1" @@ -482,39 +483,68 @@ def seed_fifth_domain_channel(binary: pathlib.Path) -> str: def configure_shared_channel_repository( repository: pathlib.Path = CHANNEL_REPOSITORY_PATH, + generated_hook: pathlib.Path | None = None, + acl_runner: Callable[[list[str]], None] | None = None, ) -> str: - """Use Git's native shared-repository mode instead of a post-receive chmod.""" + """Share only this repository through exact inherited ACLs.""" if not repository.is_dir(): raise RuntimeError("channel repository path unavailable") - subprocess.run( + unset_result = subprocess.run( [ "git", "--git-dir", str(repository), "config", + "--unset-all", "core.sharedRepository", - "group", ], - check=True, capture_output=True, text=True, ) - configured = subprocess.run( + if unset_result.returncode not in {0, 5}: + raise RuntimeError("shared repository configuration removal failed") + + def apply_acl(command: list[str]) -> None: + if acl_runner: + acl_runner(command) + return + subprocess.run(command, check=True, capture_output=True, text=True) + + objects = repository / "objects" + refs = repository / "refs" + apply_acl( [ - "git", - "--git-dir", - str(repository), - "config", - "--get", - "core.sharedRepository", - ], - check=True, - capture_output=True, - text=True, - ).stdout.strip() - if configured not in {"1", "group"}: - raise RuntimeError("shared repository configuration verification failed") + "/usr/bin/setfacl", + "-R", + "-m", + "u:guanghu-authz:rwX,m::rwx", + str(objects), + str(refs), + ] + ) + for root in (objects, refs): + for directory, child_directories, _files in os.walk(root): + apply_acl( + [ + "/usr/bin/setfacl", + "-m", + "d:u:guanghu-authz:rwx,d:m::rwx", + directory, + ] + ) + child_directories.sort() + for name, permissions in (("HEAD", "rw"), ("packed-refs", "rw"), ("config", "r")): + path = repository / name + if path.is_file(): + apply_acl( + [ + "/usr/bin/setfacl", + "-m", + f"u:guanghu-authz:{permissions}", + str(path), + ] + ) obsolete_hook = ( repository @@ -530,6 +560,23 @@ def configure_shared_channel_repository( raise RuntimeError("obsolete sharing hook identity mismatch") obsolete_hook.unlink() + generated_hook = generated_hook or ( + STATE_ROOT / "data" / "data" / "home" / "hooks" / "post-receive" + ) + if not generated_hook.is_file() or generated_hook.is_symlink(): + raise RuntimeError("generated post-receive hook path unavailable") + generated_text = generated_hook.read_text(encoding="utf-8") + unsafe_test = 'if [ $(basename "${hook}") != "gitea" ]; then' + safe_test = 'if [ "$(basename "${hook}")" != "gitea" ]; then' + if unsafe_test in generated_text: + generated_hook.write_text( + generated_text.replace(unsafe_test, safe_test, 1), + encoding="utf-8", + ) + generated_hook.chmod(0o700) + elif safe_test not in generated_text: + raise RuntimeError("generated post-receive hook identity mismatch") + return "configured" diff --git a/server-tools/hololake-code-channel/jd-candidate/native.test.js b/server-tools/hololake-code-channel/jd-candidate/native.test.js index bd6438f..672aced 100644 --- a/server-tools/hololake-code-channel/jd-candidate/native.test.js +++ b/server-tools/hololake-code-channel/jd-candidate/native.test.js @@ -48,8 +48,12 @@ assert.match(bootstrap, /access_tokens_migrated": False/); assert.match(bootstrap, /repositories_migrated": False/); assert.match(bootstrap, /delete from access_token/); assert.match(bootstrap, /core\.sharedRepository/); -assert.match(bootstrap, /"group"/); +assert.match(bootstrap, /"--unset-all"/); +assert.match(bootstrap, /\/usr\/bin\/setfacl/); +assert.match(bootstrap, /d:u:guanghu-authz:rwx/); assert.match(bootstrap, /obsolete sharing hook identity mismatch/); +assert.match(bootstrap, /generated post-receive hook identity mismatch/); +assert.match(bootstrap, /basename "\$\{hook\}"/); assert.match(bootstrap, /configure_shared_channel_repository\(\)/); assert.doesNotMatch( bootstrap, diff --git a/server-tools/hololake-code-channel/jd-candidate/test_owner_migration.py b/server-tools/hololake-code-channel/jd-candidate/test_owner_migration.py index 75cdf80..67e8e56 100644 --- a/server-tools/hololake-code-channel/jd-candidate/test_owner_migration.py +++ b/server-tools/hololake-code-channel/jd-candidate/test_owner_migration.py @@ -150,9 +150,19 @@ class SharedRepositoryTests(unittest.TestCase): "#!/bin/sh\nprintf '%s\\n' post_receive_permissions_reconciled\n", encoding="utf-8", ) + generated_hook = pathlib.Path(temporary) / "generated-post-receive" + generated_hook.write_text( + '#!/usr/bin/env bash\nif [ $(basename "${hook}") != "gitea" ]; then\n :\nfi\n', + encoding="utf-8", + ) + acl_commands = [] self.assertEqual( - MODULE.configure_shared_channel_repository(repository), + MODULE.configure_shared_channel_repository( + repository, + generated_hook, + acl_commands.append, + ), "configured", ) configured = subprocess.run( @@ -164,12 +174,22 @@ class SharedRepositoryTests(unittest.TestCase): "--get", "core.sharedRepository", ], - check=True, capture_output=True, text=True, - ).stdout.strip() - self.assertIn(configured, {"1", "group"}) + ) + self.assertNotEqual(configured.returncode, 0) + self.assertTrue( + any( + "d:u:guanghu-authz:rwx" in argument + for command in acl_commands + for argument in command + ) + ) self.assertFalse(hook.exists()) + self.assertIn( + 'if [ "$(basename "${hook}")" != "gitea" ]; then', + generated_hook.read_text(encoding="utf-8"), + ) def test_refuses_unknown_hook_content(self) -> None: with tempfile.TemporaryDirectory() as temporary: @@ -178,9 +198,18 @@ class SharedRepositoryTests(unittest.TestCase): hook = repository / "hooks" / "post-receive.d" / "guanghu-ice-heart-share" hook.parent.mkdir(parents=True) hook.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8") + generated_hook = pathlib.Path(temporary) / "generated-post-receive" + generated_hook.write_text( + '#!/usr/bin/env bash\nif [ $(basename "${hook}") != "gitea" ]; then\n :\nfi\n', + encoding="utf-8", + ) with self.assertRaisesRegex(RuntimeError, "identity mismatch"): - MODULE.configure_shared_channel_repository(repository) + MODULE.configure_shared_channel_repository( + repository, + generated_hook, + lambda _command: None, + ) self.assertTrue(hook.exists()) diff --git a/server-tools/lake-lamp-authz/README.md b/server-tools/lake-lamp-authz/README.md index 49f3f1d..576afd3 100644 --- a/server-tools/lake-lamp-authz/README.md +++ b/server-tools/lake-lamp-authz/README.md @@ -125,9 +125,11 @@ node server-tools/lake-lamp-authz/authorize-repo-push.js \ `safe.directory`,不得使用通配符。 光湖代码频道本身保留 `UMask=0077`,以免放宽数据库和其他状态目录。对需要由直达 -接收器共同读写的精确裸仓库设置 Git 原生 `core.sharedRepository=group`,让新建的 -`objects` 与 `refs` 从写入时就继承专用共享组权限。不要安装推送后递归 `chmod` 的 -钩子,也不要为了共享一个裸仓库而修改整个代码频道服务的 UMask。 +接收器共同读写的精确裸仓库不设置 `core.sharedRepository`,而只在该仓库的 +`objects` 与 `refs` 上为 `guanghu-authz` 设置继承 ACL。这样新对象和多级分支目录 +从创建时就允许精确接收账户访问,不要求安全沙箱禁止的 setgid 操作,也不扩大到代码 +频道数据库或其他状态。不要安装推送后递归 `chmod` 的钩子,也不要为了共享一个裸仓库 +而修改整个代码频道服务的 UMask。 部署后用当前主分支生成无变化验收 bundle,经 `receiveBundle` 完整执行一次;验收前后 主分支 SHA 必须一致,并且代码频道账户与授权服务账户执行 `git fsck` 均通过。