fix(hlcc): persist exact repository ACL sharing
This commit is contained in:
parent
bf5ebcdd0e
commit
ba6aca7f24
5 changed files with 173 additions and 27 deletions
|
|
@ -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"
|
||||||
|
}
|
||||||
|
|
@ -18,6 +18,7 @@ import time
|
||||||
import urllib.error
|
import urllib.error
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
import urllib.request
|
import urllib.request
|
||||||
|
from collections.abc import Callable
|
||||||
|
|
||||||
|
|
||||||
VERSION = "16.0.1"
|
VERSION = "16.0.1"
|
||||||
|
|
@ -482,39 +483,68 @@ def seed_fifth_domain_channel(binary: pathlib.Path) -> str:
|
||||||
|
|
||||||
def configure_shared_channel_repository(
|
def configure_shared_channel_repository(
|
||||||
repository: pathlib.Path = CHANNEL_REPOSITORY_PATH,
|
repository: pathlib.Path = CHANNEL_REPOSITORY_PATH,
|
||||||
|
generated_hook: pathlib.Path | None = None,
|
||||||
|
acl_runner: Callable[[list[str]], None] | None = None,
|
||||||
) -> str:
|
) -> 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():
|
if not repository.is_dir():
|
||||||
raise RuntimeError("channel repository path unavailable")
|
raise RuntimeError("channel repository path unavailable")
|
||||||
|
|
||||||
subprocess.run(
|
unset_result = subprocess.run(
|
||||||
[
|
[
|
||||||
"git",
|
"git",
|
||||||
"--git-dir",
|
"--git-dir",
|
||||||
str(repository),
|
str(repository),
|
||||||
"config",
|
"config",
|
||||||
|
"--unset-all",
|
||||||
"core.sharedRepository",
|
"core.sharedRepository",
|
||||||
"group",
|
|
||||||
],
|
],
|
||||||
check=True,
|
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=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",
|
"/usr/bin/setfacl",
|
||||||
"--git-dir",
|
"-R",
|
||||||
str(repository),
|
"-m",
|
||||||
"config",
|
"u:guanghu-authz:rwX,m::rwx",
|
||||||
"--get",
|
str(objects),
|
||||||
"core.sharedRepository",
|
str(refs),
|
||||||
],
|
]
|
||||||
check=True,
|
)
|
||||||
capture_output=True,
|
for root in (objects, refs):
|
||||||
text=True,
|
for directory, child_directories, _files in os.walk(root):
|
||||||
).stdout.strip()
|
apply_acl(
|
||||||
if configured not in {"1", "group"}:
|
[
|
||||||
raise RuntimeError("shared repository configuration verification failed")
|
"/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 = (
|
obsolete_hook = (
|
||||||
repository
|
repository
|
||||||
|
|
@ -530,6 +560,23 @@ def configure_shared_channel_repository(
|
||||||
raise RuntimeError("obsolete sharing hook identity mismatch")
|
raise RuntimeError("obsolete sharing hook identity mismatch")
|
||||||
obsolete_hook.unlink()
|
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"
|
return "configured"
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -48,8 +48,12 @@ assert.match(bootstrap, /access_tokens_migrated": False/);
|
||||||
assert.match(bootstrap, /repositories_migrated": False/);
|
assert.match(bootstrap, /repositories_migrated": False/);
|
||||||
assert.match(bootstrap, /delete from access_token/);
|
assert.match(bootstrap, /delete from access_token/);
|
||||||
assert.match(bootstrap, /core\.sharedRepository/);
|
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, /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.match(bootstrap, /configure_shared_channel_repository\(\)/);
|
||||||
assert.doesNotMatch(
|
assert.doesNotMatch(
|
||||||
bootstrap,
|
bootstrap,
|
||||||
|
|
|
||||||
|
|
@ -150,9 +150,19 @@ class SharedRepositoryTests(unittest.TestCase):
|
||||||
"#!/bin/sh\nprintf '%s\\n' post_receive_permissions_reconciled\n",
|
"#!/bin/sh\nprintf '%s\\n' post_receive_permissions_reconciled\n",
|
||||||
encoding="utf-8",
|
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(
|
self.assertEqual(
|
||||||
MODULE.configure_shared_channel_repository(repository),
|
MODULE.configure_shared_channel_repository(
|
||||||
|
repository,
|
||||||
|
generated_hook,
|
||||||
|
acl_commands.append,
|
||||||
|
),
|
||||||
"configured",
|
"configured",
|
||||||
)
|
)
|
||||||
configured = subprocess.run(
|
configured = subprocess.run(
|
||||||
|
|
@ -164,12 +174,22 @@ class SharedRepositoryTests(unittest.TestCase):
|
||||||
"--get",
|
"--get",
|
||||||
"core.sharedRepository",
|
"core.sharedRepository",
|
||||||
],
|
],
|
||||||
check=True,
|
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=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.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:
|
def test_refuses_unknown_hook_content(self) -> None:
|
||||||
with tempfile.TemporaryDirectory() as temporary:
|
with tempfile.TemporaryDirectory() as temporary:
|
||||||
|
|
@ -178,9 +198,18 @@ class SharedRepositoryTests(unittest.TestCase):
|
||||||
hook = repository / "hooks" / "post-receive.d" / "guanghu-ice-heart-share"
|
hook = repository / "hooks" / "post-receive.d" / "guanghu-ice-heart-share"
|
||||||
hook.parent.mkdir(parents=True)
|
hook.parent.mkdir(parents=True)
|
||||||
hook.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8")
|
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"):
|
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())
|
self.assertTrue(hook.exists())
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -125,9 +125,11 @@ node server-tools/lake-lamp-authz/authorize-repo-push.js \
|
||||||
`safe.directory`,不得使用通配符。
|
`safe.directory`,不得使用通配符。
|
||||||
|
|
||||||
光湖代码频道本身保留 `UMask=0077`,以免放宽数据库和其他状态目录。对需要由直达
|
光湖代码频道本身保留 `UMask=0077`,以免放宽数据库和其他状态目录。对需要由直达
|
||||||
接收器共同读写的精确裸仓库设置 Git 原生 `core.sharedRepository=group`,让新建的
|
接收器共同读写的精确裸仓库不设置 `core.sharedRepository`,而只在该仓库的
|
||||||
`objects` 与 `refs` 从写入时就继承专用共享组权限。不要安装推送后递归 `chmod` 的
|
`objects` 与 `refs` 上为 `guanghu-authz` 设置继承 ACL。这样新对象和多级分支目录
|
||||||
钩子,也不要为了共享一个裸仓库而修改整个代码频道服务的 UMask。
|
从创建时就允许精确接收账户访问,不要求安全沙箱禁止的 setgid 操作,也不扩大到代码
|
||||||
|
频道数据库或其他状态。不要安装推送后递归 `chmod` 的钩子,也不要为了共享一个裸仓库
|
||||||
|
而修改整个代码频道服务的 UMask。
|
||||||
|
|
||||||
部署后用当前主分支生成无变化验收 bundle,经 `receiveBundle` 完整执行一次;验收前后
|
部署后用当前主分支生成无变化验收 bundle,经 `receiveBundle` 完整执行一次;验收前后
|
||||||
主分支 SHA 必须一致,并且代码频道账户与授权服务账户执行 `git fsck` 均通过。
|
主分支 SHA 必须一致,并且代码频道账户与授权服务账户执行 `git fsck` 均通过。
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue