From 00ba8f79cb25316bc33690f5fde96e2546176b21 Mon Sep 17 00:00:00 2001 From: LIghtJUNction Date: Tue, 29 Sep 2026 17:09:19 +0800 Subject: [PATCH 1/4] test: define hosting read-only role acceptance and cloud regression workflow --- .github/workflows/hosting-readonly.yml | 79 +++++++++++ docs/HOSTING_READONLY_PROGRESS.md | 19 +++ tests/test_hosting_readonly.py | 183 +++++++++++++++++++++++++ 3 files changed, 281 insertions(+) create mode 100644 .github/workflows/hosting-readonly.yml create mode 100644 docs/HOSTING_READONLY_PROGRESS.md create mode 100644 tests/test_hosting_readonly.py diff --git a/.github/workflows/hosting-readonly.yml b/.github/workflows/hosting-readonly.yml new file mode 100644 index 00000000..3886ec31 --- /dev/null +++ b/.github/workflows/hosting-readonly.yml @@ -0,0 +1,79 @@ +name: Hosting read-only role +on: + push: + branches: ['fix/hosting-readonly-*'] + pull_request: + paths: + - 'src/msg/hosting_runtime.py' + - 'src/msg/composition.py' + - 'src/msg/application.py' + - 'src/msg/daemon.py' + - 'src/msg/storage/**' + - 'src/msg/security/**' + - 'src/msg/extensions/hosting.py' + - 'tests/test_hosting_readonly*.py' + - 'deploy/msgd-hosting.service' + - '.github/workflows/hosting-readonly.yml' + workflow_dispatch: +permissions: + contents: read +concurrency: + group: hosting-readonly-${{ github.event.pull_request.head.repo.full_name || github.repository }}-${{ github.head_ref || github.ref_name }} + cancel-in-progress: true +jobs: + hosting-role: + runs-on: ubuntu-latest + timeout-minutes: 20 + services: + postgres: + image: postgres:16 + env: + POSTGRES_USER: msgtest + POSTGRES_PASSWORD: msgtest + POSTGRES_DB: postgres + ports: ['5432:5432'] + options: >- + --health-cmd "pg_isready -U msgtest" + --health-interval 10s --health-timeout 5s --health-retries 5 + env: + MSG_TEST_POSTGRES_URL_TEMPLATE: postgresql://msgtest:msgtest@127.0.0.1:5432/{database} + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + - uses: actions/setup-python@v7 + with: + python-version: '3.15' + allow-prereleases: true + cache: pip + - name: Record tested source and runtime + env: + HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }} + run: | + mkdir -p artifacts + python - <<'PY' + import json, os, pathlib, platform, subprocess + def git(*args): + return subprocess.check_output(['git', *args], text=True).strip() + pathlib.Path('artifacts/source.json').write_text(json.dumps({ + 'head': os.environ['HEAD_SHA'], 'checkout': git('rev-parse', 'HEAD'), + 'tree': git('rev-parse', 'HEAD^{tree}'), 'python': platform.python_version(), + 'run_id': os.environ['GITHUB_RUN_ID'], 'attempt': os.environ['GITHUB_RUN_ATTEMPT'], + 'event': os.environ['GITHUB_EVENT_NAME'], + }, indent=2) + '\n') + PY + - name: Install isolated prerequisites + run: | + sudo apt-get update -qq + sudo apt-get install -y -qq age git-lfs + python -m pip install -e '.[dev]' + - name: Verify read-only role and existing hosting contracts + run: >- + python -m pytest tests/test_hosting_readonly*.py + tests/test_hosting_same_origin.py tests/test_cli_hosting.py + --junitxml=artifacts/hosting.xml + - uses: actions/upload-artifact@v4 + if: always() + with: + name: hosting-readonly + path: artifacts/ diff --git a/docs/HOSTING_READONLY_PROGRESS.md b/docs/HOSTING_READONLY_PROGRESS.md new file mode 100644 index 00000000..e2a739b3 --- /dev/null +++ b/docs/HOSTING_READONLY_PROGRESS.md @@ -0,0 +1,19 @@ +# Hosting 只读角色推进笔记 + +Refs #161 #80 #83 #85。分支 `fix/hosting-readonly-161-20260929`;基线 main `261c8816cd581deaf69fc0c810b8fa6dad5ead5e` / tree `6171f6041ffd0addaf72eadd35acf562f478281f`。协调登记 #83 comment5887036549。 + +## 边界 + +主责 hosting 装配、必要只读存储模式、部署模板与测试。daemon 仅窄接线,Application 仅抽取共用登记装配,不动 #172 凭据交付、#167 执行器/存储会话语义。#168 市场、#170 Git、#171 客户端、#155 恢复的分支不覆盖;共享文件最终组合仍须串行审查。 + +## 本次提交:测试先行 + +新增真正 PostgreSQL 只读角色 fixture、无服务密钥启动/禁止规则同步、Registry 契约元数据与不可执行回调、数据库写入口/真实 SQL 拒绝、Git 读取/写入拒绝、错误 writer DSN、缺规则不修复、独立 OS 身份模板检查。仅测试和工作流,尚无生产实现;预期红基线须读取实际云端报告后记录,不能提前记为已验证。 + +`Hosting read-only role` push/PR 工作流运行 Python 3.15、真实 PostgreSQL/Git/age,保留现有 hosting/CLI 回归,上传 source.json 与 JUnit。项目代码不在本地执行;本地只做文本、差异、源码和下载产物检查。完整四分片/node-ID/conformance/wheel/sdist 门槛不被专项替代。 + +## 后续 + +实现仅认证/当前授权/读取的运行上下文;不加载在线/回执/token 私钥,不持有 Application/写执行器,不初始化数据库/目录或发布规则。数据库专用 SELECT 角色与 OS 只读权限分别验证;私有 preview、当前撤权、HEAD/Range/暖缓存、恢复 quarantine 继续拒绝越权。 + +没有生产部署、权限变更、Root/PIN、资金、真实外发、备份销毁或恢复放行。#64/#65/#84 等现场材料缺失不通过本项关闭。 diff --git a/tests/test_hosting_readonly.py b/tests/test_hosting_readonly.py new file mode 100644 index 00000000..1a739771 --- /dev/null +++ b/tests/test_hosting_readonly.py @@ -0,0 +1,183 @@ +"""A hosting process reads current authority without acquiring writer capabilities.""" +from dataclasses import replace +import importlib.util +from pathlib import Path +import uuid + +import httpx +import psycopg +from psycopg import sql +from psycopg.conninfo import make_conninfo +import pytest + +from msg.core.errors import Failure +from msg.core.models import ResourceRef +from msg.storage.git import GitContentStore +from msg.storage.postgres import PostgresMetadataStore + + +READ_TABLES = ('schema_version', 'resources', 'revisions', 'identities', + 'credentials', 'certificates', 'memberships', 'settings', 'csrs', + 'share_grants', 'share_grants_v2', 'topic_bans', 'dm_conversations', + 'dm_blocks', 'system_sources') + + +def runtime(settings, clock): + assert importlib.util.find_spec('msg.hosting_runtime') is not None, \ + 'hosting still has no independent read-only composition' + from msg.hosting_runtime import HostingRuntime + return HostingRuntime(settings, clock=clock) + + +@pytest.fixture +async def reader_settings(installed): + app, _ = installed + role = 'hosting_test_' + uuid.uuid4().hex + password = uuid.uuid4().hex + owner_dsn = app.settings.server.postgres_dsn + with psycopg.connect(owner_dsn, autocommit=True) as conn: + database = conn.info.dbname + conn.execute(sql.SQL('CREATE ROLE {} LOGIN PASSWORD {} NOSUPERUSER NOCREATEDB ' + 'NOCREATEROLE NOINHERIT NOREPLICATION NOBYPASSRLS').format( + sql.Identifier(role), sql.Literal(password))) + conn.execute(sql.SQL('REVOKE CREATE, TEMPORARY ON DATABASE {} FROM PUBLIC').format( + sql.Identifier(database))) + conn.execute('REVOKE CREATE ON SCHEMA public FROM PUBLIC') + conn.execute(sql.SQL('GRANT CONNECT ON DATABASE {} TO {}').format( + sql.Identifier(database), sql.Identifier(role))) + conn.execute(sql.SQL('GRANT USAGE ON SCHEMA public TO {}').format(sql.Identifier(role))) + conn.execute(sql.SQL('GRANT SELECT ON {} TO {}').format( + sql.SQL(', ').join(sql.Identifier('public', name) for name in READ_TABLES), + sql.Identifier(role))) + try: + yield replace(app.settings, server=replace(app.settings.server, + postgres_dsn=make_conninfo(owner_dsn, user=role, password=password))) + finally: + with psycopg.connect(owner_dsn, autocommit=True) as conn: + conn.execute(sql.SQL('DROP OWNED BY {}').format(sql.Identifier(role))) + conn.execute(sql.SQL('DROP ROLE {}').format(sql.Identifier(role))) + + +@pytest.mark.asyncio +async def test_hosting_starts_without_service_secrets_or_release_writes(installed, reader_settings, monkeypatch): + app, _ = installed + keys = app.settings.service_keys + moved = keys.with_name(keys.name + '-hidden') + keys.rename(moved) + async def forbidden_sync(*args, **kwargs): + pytest.fail('read-only startup invoked release publication') + monkeypatch.setattr('msg.bootstrap.sync_system_sources', forbidden_sync) + try: + service = runtime(reader_settings, app.clock) + await service.load() + try: + for name in ('executor', 'online_signer', 'receipt_signer', '_token_secret', + '_vault_key', 'issued_token', 'record_token_delivery'): + assert not hasattr(service, name), name + from msg.extensions.hosting import hosting_app + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=hosting_app(service)), + base_url='http://testserver') as http: + page = await http.get('/@root/web/index.html') + assert page.status_code == 200, page.text + assert 'sandbox' in page.headers['content-security-policy'] + assert 'set-cookie' not in page.headers + head = await http.head('/@root/web/index.html') + assert head.status_code == 200 and head.content == b'' + part = await http.get('/@root/web/index.html', headers={'Range': 'bytes=0-7'}) + assert part.status_code == 206 and part.content == page.content[:8] + cached = await http.get('/@root/web/index.html', headers={'If-None-Match': page.headers['etag']}) + assert cached.status_code == 304 + assert (await http.post('/@root/web/index.html', content=b'write')).status_code == 405 + finally: + await service.close() + finally: + moved.rename(keys) + + +@pytest.mark.asyncio +async def test_hosting_catalog_keeps_contract_metadata_but_no_business_handlers(installed, reader_settings): + app, _ = installed + service = runtime(reader_settings, app.clock) + assert [app.registry.describe(s) for s in app.registry.operations()] == [ + service.registry.describe(s) for s in service.registry.operations()] + assert app.registry.capabilities() == service.registry.capabilities() + for spec in service.registry.operations(): + with pytest.raises(Failure, match='read_only_role'): + await spec.handler(None, None, None) + with pytest.raises(Failure, match='read_only_role'): + await spec.requirements(None, None) + for manifest in service.registry._plugins.values(): + for spec in manifest.operations: + assert spec.handler is service.registry.operation(spec.name, spec.version).handler + + +@pytest.mark.asyncio +async def test_postgres_reader_rejects_write_before_transaction(installed, reader_settings): + store = PostgresMetadataStore(reader_settings.server.postgres_dsn, + initialize=False, read_only=True) + try: + async with store.transaction(write=False) as tx: + assert tx.one('SHOW transaction_read_only') == ('on',) + assert tx.one('SELECT COUNT(*) FROM resources')[0] > 0 + with pytest.raises(Failure, match='read_only_role'): + async with store.transaction(write=True): + pytest.fail('write transaction was entered') + with psycopg.connect(reader_settings.server.postgres_dsn) as conn: + with pytest.raises(psycopg.errors.InsufficientPrivilege): + conn.execute("UPDATE settings SET value=value WHERE key='active_root_certificate'") + finally: + await store.close() + + +@pytest.mark.asyncio +async def test_git_reader_preserves_read_and_rejects_all_mutations(installed): + app, _ = installed + async with app.metadata.transaction(write=False) as tx: + rev = await tx.revision(ResourceRef(id='r_agents')) + source = app.contents + reader = GitContentStore(source.path, binary_dir=source.binary, + staging_dir=source.path/'must-not-create', read_only=True) + assert not reader.staging.exists() + assert await reader.read_bytes(rev.content) == await source.read_bytes(rev.content) + for action in (lambda: reader.put_bytes(b'no'), lambda: reader.pin(rev.content, 'no'), + lambda: reader.unpin(rev.content, 'no'), + lambda: reader.commit_revision('r_rules', rev)): + with pytest.raises(Failure, match='read_only_role'): + await action() + assert not reader.staging.exists() + + +@pytest.mark.asyncio +async def test_hosting_refuses_writer_database_credentials(installed): + app, _ = installed + service = runtime(app.settings, app.clock) + try: + with pytest.raises(Failure, match='hosting_database_role_not_readonly'): + await service.load() + finally: + await service.close() + + +@pytest.mark.asyncio +async def test_missing_release_source_is_not_silently_repaired(installed, reader_settings): + app, _ = installed + async with app.metadata.transaction(write=True) as tx: + tx.execute("DELETE FROM system_sources WHERE resource_id='r_agents'", write=True) + service = runtime(reader_settings, app.clock) + try: + with pytest.raises(Failure, match='hosting_release_not_ready'): + await service.load() + finally: + await service.close() + async with app.metadata.transaction(write=False) as tx: + assert tx.one("SELECT 1 FROM system_sources WHERE resource_id='r_agents'") is None + + +def test_hosting_unit_uses_dedicated_identity_and_read_only_mounts(): + unit = (Path(__file__).resolve().parents[1]/'deploy/msgd-hosting.service').read_text() + assert 'User=msgd-hosting\n' in unit + assert 'Group=msgd-hosting\n' in unit + assert 'ReadWritePaths=' not in unit + assert 'ReadOnlyPaths=' in unit + assert '--config-dir /etc/msgd-hosting hosting' in unit + assert '/var/lib/msgd/service' in unit From 0f0e13d1fe7d152a9034b4028ae2e1e7e2d06966 Mon Sep 17 00:00:00 2001 From: msg maintenance CI <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 29 Sep 2026 09:19:28 +0000 Subject: [PATCH 2/4] refactor: isolate read-only hosting without writer credentials or startup mutations --- deploy/msgd-hosting.service | 17 +++-- src/msg/application.py | 17 +---- src/msg/bootstrap.py | 24 ++++++ src/msg/composition.py | 21 ++++++ src/msg/daemon.py | 15 +++- src/msg/extensions/hosting.py | 2 + src/msg/hosting_runtime.py | 138 ++++++++++++++++++++++++++++++++++ src/msg/storage/git.py | 29 +++++-- src/msg/storage/postgres.py | 7 +- 9 files changed, 240 insertions(+), 30 deletions(-) create mode 100644 src/msg/composition.py create mode 100644 src/msg/hosting_runtime.py diff --git a/deploy/msgd-hosting.service b/deploy/msgd-hosting.service index 16b12bd7..a42d9207 100644 --- a/deploy/msgd-hosting.service +++ b/deploy/msgd-hosting.service @@ -1,21 +1,24 @@ [Unit] -Description=msg.lmm.best separate-origin static hosting +Description=msg.lmm.best read-only static hosting After=network.target [Service] Type=simple -User=msgd -Group=msgd -WorkingDirectory=/var/lib/msgd -ExecStart=/opt/msgd/venv/bin/msgd hosting --listen 127.0.0.1 --port 8043 +User=msgd-hosting +Group=msgd-hosting +WorkingDirectory=/ +ExecStart=/opt/msgd/venv/bin/msgd --config-dir /etc/msgd-hosting hosting --listen 127.0.0.1 --port 8043 Restart=on-failure UMask=0077 NoNewPrivileges=true PrivateTmp=true ProtectSystem=strict ProtectHome=true -ReadWritePaths=/var/lib/msgd -InaccessiblePaths=/var/lib/msgd-root /etc/msgd/root +ReadOnlyPaths=/etc/msgd-hosting /var/lib/msgd/git/content /var/lib/msgd/blobs/sha256 +InaccessiblePaths=/var/lib/msgd-root /etc/msgd /var/lib/msgd/service -/var/lib/msgd/transfers -/var/lib/msgd/git/repos +CapabilityBoundingSet= +RestrictSUIDSGID=true +LockPersonality=true [Install] WantedBy=multi-user.target diff --git a/src/msg/application.py b/src/msg/application.py index 1862a579..40cfc4ed 100644 --- a/src/msg/application.py +++ b/src/msg/application.py @@ -6,9 +6,9 @@ from pathlib import Path import hashlib import hmac -import importlib from msg.constants import ROOT_SPACE, ROOT_SUBJECT +from msg.composition import install_contracts from msg.core.codec import loads, decode, unb64, b64, canonical, wire from msg.core.cursors import CursorCodec from msg.core.errors import Failure, require @@ -18,7 +18,7 @@ from msg.core.registry import Registry from msg.security.authentication import AuthenticationService from msg.security.authorization import AuthorizationService -from msg.security.capabilities import install_capabilities, primary_ceiling, base_grants, temporary_ceiling +from msg.security.capabilities import primary_ceiling, base_grants, temporary_ceiling from msg.security.certificates import CertificateValidator from msg.security.crypto import Ed25519Signer from msg.security.quarantine import active as quarantine_active @@ -40,18 +40,7 @@ def __init__(self,settings, *, clock=None, selftest_run_id=None): self.contents=None self.executor=None self._loaded=False - # Plugins are installed code, never resources, posts, or configuration expressions. - implemented=('identity','content','discussion','communication','discovery','achievements','recovery','sharing','money','offers','store','bounty','orders','delivery') - configured=set(settings.server.plugins) - require(configured<=set(implemented)|{'transfer','extensions','system','batch'},'unknown_plugin') - for plugin in implemented: - if plugin in configured: - importlib.import_module('msg.plugins.'+plugin).install(self) - for plugin in ('transfer','extensions','system','batch'): - if plugin in configured: - importlib.import_module('msg.plugins.'+plugin).install(self) - install_capabilities(self.registry) - self.registry.freeze() + install_contracts(self) def primary_ceiling(self): return primary_ceiling(self.registry,self.default_scope()) diff --git a/src/msg/bootstrap.py b/src/msg/bootstrap.py index c8fb71fe..569aa43d 100644 --- a/src/msg/bootstrap.py +++ b/src/msg/bootstrap.py @@ -170,6 +170,30 @@ async def sync_system_sources(tx,contents,now, *, source_root=None,namespace_roo require(not missing,'system_source_deleted_requires_migration',details={'paths':sorted(missing)}) +async def require_current_system_sources(tx, contents): + """Check an installed release without migrations, publication or repair.""" + root = Path(system_source_root()) + require(tx.one('SELECT COUNT(*) FROM system_sources')[0] == len(RULE_SPECS), + 'hosting_release_not_ready') + for rule_id, rid, _, _, default_path, max_bytes in RULE_SPECS: + relative = SOURCE_PATH_OVERRIDES.get(rule_id, default_path) + raw = (root/relative).read_bytes() + header = SOURCE_HEADER.match(raw.decode('utf-8')) + require(header is not None and header.group(1) == rule_id, 'system_source_invalid_header') + version = int(header.group(2)) + row = tx.one('SELECT source_path,rule_id,source_kind,source_version,source_digest,revision_id ' + 'FROM system_sources WHERE resource_id=?', (rid,)) + require(row is not None and row[:5] == ('docs/system/'+relative, rule_id, 'release', + version, digest(raw)), 'hosting_release_not_ready') + resource = await tx.resource(rid) + require(resource.revision == row[5], 'hosting_release_not_ready') + revision = await tx.revision(ResourceRef(id=rid, revision=resource.revision)) + require(revision.source_kind == 'release' and revision.source_version == version and + revision.source_digest == digest(raw) and + await contents.read_bytes(revision.content, limit=max_bytes) == raw, + 'hosting_release_not_ready') + + def manifest(): return loads(files('msg.data').joinpath('bootstrap.json').read_bytes()) diff --git a/src/msg/composition.py b/src/msg/composition.py new file mode 100644 index 00000000..2361488e --- /dev/null +++ b/src/msg/composition.py @@ -0,0 +1,21 @@ +"""One trusted registration sequence for executable and metadata-only roles.""" +import importlib + +from msg.core.errors import require +from msg.security.capabilities import install_capabilities + + +PLUGIN_ORDER = ('identity', 'content', 'discussion', 'communication', 'discovery', + 'achievements', 'recovery', 'sharing', 'money', 'offers', 'store', + 'bounty', 'orders', 'delivery', 'transfer', 'extensions', 'system', 'batch') + + +def install_contracts(target): + # Installers declare contracts; they neither initialize storage nor run jobs. + configured = set(target.settings.server.plugins) + require(configured <= set(PLUGIN_ORDER), 'unknown_plugin') + for plugin in PLUGIN_ORDER: + if plugin in configured: + importlib.import_module('msg.plugins.' + plugin).install(target) + install_capabilities(target.registry) + target.registry.freeze() diff --git a/src/msg/daemon.py b/src/msg/daemon.py index 637715da..d4df7003 100644 --- a/src/msg/daemon.py +++ b/src/msg/daemon.py @@ -31,6 +31,12 @@ def load_application(directory): return Application(load_settings(directory)) +def load_hosting(directory): + from msg.hosting_runtime import HostingRuntime + from msg.config import load_settings + return HostingRuntime(load_settings(directory)) + + async def worker_loop(app, *, once=False): from msg.workers.effects import EffectWorker from msg.workers.maintenance import run_maintenance @@ -208,7 +214,8 @@ def main(argv=None): if args.command=='ssh-session': from msg.extensions.ssh import forced_session return asyncio.run(forced_session(args.config_dir,args.credential)) - app=load_application(args.config_dir) + app=(load_hosting(args.config_dir) if args.command=='hosting' + else load_application(args.config_dir)) if args.command=='backup': from msg.admin.backups import backup async def save(): @@ -230,8 +237,10 @@ async def save(): @asynccontextmanager async def lifespan(asgi): await app.load() - yield - await app.close() + try: + yield + finally: + await app.close() asgi=hosting_app(app);asgi.router.lifespan_context=lifespan uvicorn.run(asgi,host=args.listen,port=args.port,access_log=False,ws='none') else: diff --git a/src/msg/extensions/hosting.py b/src/msg/extensions/hosting.py index 380ddee4..bcf76d70 100644 --- a/src/msg/extensions/hosting.py +++ b/src/msg/extensions/hosting.py @@ -160,6 +160,8 @@ async def serve_hosted(service,request): return None site_path='/'+parts[0]+'/'+parts[1] try: + marker=service.settings.config_dir/'recovery-drill.json' + require(not marker.exists() and not marker.is_symlink(),'recovery_quarantined') async with service.metadata.transaction(write=False) as tx: try: rid=await tx.resolve(site_path) diff --git a/src/msg/hosting_runtime.py b/src/msg/hosting_runtime.py new file mode 100644 index 00000000..67c90df6 --- /dev/null +++ b/src/msg/hosting_runtime.py @@ -0,0 +1,138 @@ +"""Read-only hosting composition. Public trust and current metadata are sufficient. + +This is a process role, not a Python sandbox. Its database login and OS identity +must independently deny writes and access to the writer's configuration/keys. +""" +from dataclasses import replace +from datetime import UTC, datetime + +import psycopg + +from msg.composition import install_contracts +from msg.constants import ROOT_SPACE, ROOT_SUBJECT +from msg.core.codec import decode, loads, unb64 +from msg.core.errors import Failure, require +from msg.core.models import Certificate, Scope +from msg.core.registry import Registry +from msg.security.authentication import AuthenticationService +from msg.security.authorization import AuthorizationService +from msg.security.capabilities import primary_ceiling, temporary_ceiling +from msg.security.certificates import CertificateValidator +from msg.security.quarantine import require_live_authority +from msg.storage.git import GitContentStore +from msg.storage.postgres import PostgresMetadataStore + + +async def _not_executable(*args, **kwargs): + raise Failure('read_only_role') + + +class HostingRegistry(Registry): + """Preserve signed contract metadata, discard every executable closure. + + Manifests as well as the operation index must shed callbacks; otherwise a + manifest would retain a second path to business handlers and their context. + """ + def add_operation(self, spec): + super().add_operation(replace(spec, handler=_not_executable, + requirements=_not_executable)) + + def add(self, manifest): + operations = tuple(replace(spec, handler=_not_executable, requirements=_not_executable) + for spec in manifest.operations) + super().add(replace(manifest, operations=operations)) + + +def _require_readonly_database(tx): + """Reject a writer DSN, including privileges inherited through PUBLIC. + + READ ONLY transactions remain necessary but are not a role security boundary: + a login can reset that transaction default. Check actual effective privileges. + """ + flags = tx.one('SELECT rolsuper, rolcreatedb, rolcreaterole, rolreplication, rolbypassrls ' + 'FROM pg_roles WHERE rolname=current_user') + require(flags is not None and not any(flags), 'hosting_database_role_not_readonly') + require(tx.one('SELECT 1 FROM pg_auth_members WHERE member=' + '(SELECT oid FROM pg_roles WHERE rolname=current_user) LIMIT 1') is None, + 'hosting_database_role_not_readonly') + require(not any(tx.one("SELECT has_database_privilege(current_database(), 'CREATE'), " + "has_database_privilege(current_database(), 'TEMPORARY')")), + 'hosting_database_role_not_readonly') + require(tx.one("SELECT 1 FROM pg_namespace WHERE nspname NOT LIKE 'pg_%' " + "AND nspname <> 'information_schema' AND has_schema_privilege(oid, 'CREATE') " + "LIMIT 1") is None, 'hosting_database_role_not_readonly') + require(tx.one("SELECT 1 FROM pg_class c JOIN pg_namespace n ON n.oid=c.relnamespace " + "WHERE n.nspname NOT LIKE 'pg_%' AND n.nspname <> 'information_schema' " + "AND c.relkind IN ('r','p','v','m','f') " + "AND has_table_privilege(c.oid, 'INSERT,UPDATE,DELETE,TRUNCATE,REFERENCES,TRIGGER') " + "LIMIT 1") is None, 'hosting_database_role_not_readonly') + require(tx.one("SELECT 1 FROM pg_class c JOIN pg_namespace n ON n.oid=c.relnamespace " + "WHERE n.nspname NOT LIKE 'pg_%' AND c.relkind='S' " + "AND has_sequence_privilege(c.oid, 'USAGE,UPDATE') LIMIT 1") is None, + 'hosting_database_role_not_readonly') + require(tx.one("SELECT 1 FROM pg_proc p JOIN pg_namespace n ON n.oid=p.pronamespace " + "WHERE n.nspname NOT LIKE 'pg_%' AND n.nspname <> 'information_schema' " + "AND p.prosecdef AND has_function_privilege(p.oid, 'EXECUTE') LIMIT 1") is None, + 'hosting_database_role_not_readonly') + + +class HostingRuntime: + """Only contract discovery, signature verification, current ACLs and content reads.""" + def __init__(self, settings, *, clock=None): + self.settings = settings + self.clock = clock or (lambda: datetime.now(UTC)) + self.namespace_root = ROOT_SPACE + self.registry = HostingRegistry() + self.metadata = None + self.contents = None + self._loaded = False + install_contracts(self) + + def primary_ceiling(self): + return primary_ceiling(self.registry, Scope(resource_id=ROOT_SPACE, descendants=True)) + + def temporary_ceiling(self): + return temporary_ceiling(self.registry, Scope(resource_id=ROOT_SPACE, descendants=True)) + + async def load(self): + require(self.settings.trust_file.is_file(), 'root_not_initialized') + trust = loads(self.settings.trust_file.read_bytes()) + require(set(trust) == {'version', 'public_key', 'certificate'} and trust['version'] == 1, + 'invalid_trust_anchor') + certificate = decode(Certificate, trust['certificate']) + public_key = unb64(trust['public_key'], limit=32) + self.metadata = PostgresMetadataStore(self.settings.server.postgres_dsn, + initialize=False, read_only=True) + self.contents = GitContentStore(self.settings.server.content_dir, + binary_dir=self.settings.server.blob_dir, + staging_dir=self.settings.server.staging_dir, + read_only=True) + self.certificates = CertificateValidator(self.registry, certificate, public_key, + self.settings.service_url, self.clock) + from msg.bootstrap import require_current_system_sources + try: + async with self.metadata.transaction(write=False) as tx: + _require_readonly_database(tx) + require(tx.rows('SELECT version FROM schema_version') == [(1,)], + 'hosting_installation_not_ready') + require_live_authority(tx) + await self.certificates.validate(certificate.resource_id, tx) + require((await tx.subject(ROOT_SUBJECT)).local_only, 'root_policy_corrupt') + require(tx.setting('active_root_certificate') == certificate.resource_id, + 'root_trust_restart_required') + await require_current_system_sources(tx, self.contents) + except psycopg.Error: + # Do not expose SQL, connection credentials or internal database paths. + raise Failure('hosting_installation_not_ready') from None + marker = self.settings.config_dir/'recovery-drill.json' + require(not marker.exists() and not marker.is_symlink(), 'recovery_quarantined') + self.authenticator = AuthenticationService(self.registry, self.certificates, + self.settings.service_url, self.clock, self.primary_ceiling, self.temporary_ceiling) + self.authorizer = AuthorizationService(self.registry, self.certificates) + self._loaded = True + return self + + async def close(self): + self._loaded = False + if self.metadata is not None: + await self.metadata.close() diff --git a/src/msg/storage/git.py b/src/msg/storage/git.py index 9846a21a..b64511f0 100644 --- a/src/msg/storage/git.py +++ b/src/msg/storage/git.py @@ -218,22 +218,36 @@ def collect_unlinked(self, *, older_than: float, index: Path) -> tuple[int,int]: class GitContentStore: def __init__(self,path: Path, *, binary_dir: Path | None = None, - staging_dir: Path | None = None): + staging_dir: Path | None = None, read_only: bool = False): + self.read_only=read_only self.path=Path(path) self.repo=self.path/'private.git' self.index=self.path/'index' self.binary=Path(binary_dir) if binary_dir is not None else self.path/'binary' self.staging=Path(staging_dir) if staging_dir is not None else self.path/'staging' - for p in (self.path,self.index,self.binary,self.staging): - p.mkdir(parents=True,exist_ok=True) - if not self.repo.exists(): - subprocess.run(['git','init','--bare',str(self.repo)],check=True,capture_output=True) + if read_only: + require(all(p.is_dir() and not p.is_symlink() + for p in (self.path,self.repo,self.index,self.binary)), + 'content_store_not_initialized') + else: + for p in (self.path,self.index,self.binary,self.staging): + p.mkdir(parents=True,exist_ok=True) + if not self.repo.exists(): + subprocess.run(['git','init','--bare',str(self.repo)],check=True,capture_output=True) self.env={"PATH":os.environ.get('PATH','/usr/bin:/bin'),"LANG":"C.UTF-8", "HOME":str(self.path),"GIT_CONFIG_NOSYSTEM":"1","GIT_CONFIG_GLOBAL":"/dev/null", "GIT_AUTHOR_NAME":"msg","GIT_AUTHOR_EMAIL":"msg@localhost", "GIT_COMMITTER_NAME":"msg","GIT_COMMITTER_EMAIL":"msg@localhost"} + if read_only: + # The dedicated reader does not own this administrator-selected repo. + # Trust only this exact path, never every Git directory on the host. + self.env.update(GIT_OPTIONAL_LOCKS='0', GIT_CONFIG_COUNT='1', + GIT_CONFIG_KEY_0='safe.directory', GIT_CONFIG_VALUE_0=str(self.repo)) def _run(self,*args,input=None,stdin=None): + require(not self.read_only or (len(args)==3 and args[:2]==('rev-parse','--verify') + and args[2].startswith('refs/pins/') and input is None and stdin is None), + 'read_only_role') result=subprocess.run(['git','--git-dir',str(self.repo),'-c','core.fsync=all', '-c','core.logAllRefUpdates=false',*args], input=input,stdin=stdin,capture_output=True,env=self.env,timeout=120) @@ -253,6 +267,7 @@ def _entry(self,ref): return loads(path.read_bytes()) async def put(self,chunks,media_type,expected_digest=None): + require(not self.read_only,'read_only_role') hasher=hashlib.sha256() size=0 fd,name=tempfile.mkstemp(dir=self.staging) @@ -334,6 +349,7 @@ async def read_bytes(self,blob, *, limit=1024*1024): return b''.join([chunk async for chunk in self.read(blob)]) async def _update_pin_ref(self, *args): + require(not self.read_only,'read_only_role') # Cancelling to_thread does not stop Git. Finish the mutation before the # metadata transaction can compensate it or release its writer fence. pending = asyncio.create_task(asyncio.to_thread(self._run, *args)) @@ -354,6 +370,7 @@ async def _update_pin_ref(self, *args): raise async def pin(self,blob,lease_id): + require(not self.read_only,'read_only_role') entry=self._entry(blob) name=hashlib.sha256(lease_id.encode()).hexdigest() if entry['kind']=='git': @@ -362,6 +379,7 @@ async def pin(self,blob,lease_id): durable_write(self.path/'pins'/name/self._key(blob),b'1\n') async def unpin(self,blob,lease_id): + require(not self.read_only,'read_only_role') entry=self._entry(blob) name=hashlib.sha256(lease_id.encode()).hexdigest() if entry['kind']=='git': @@ -381,6 +399,7 @@ async def pinned(self,blob,lease_id): async def commit_revision(self,topic_id,revision): """Create and protect one resource revision before the PostgreSQL pointer commits.""" + require(not self.read_only,'read_only_role') entry=self._entry(revision.content) def commit(): manifest=self._run('hash-object','-w','--stdin',input=canonical(revision)) diff --git a/src/msg/storage/postgres.py b/src/msg/storage/postgres.py index 624326e8..62d96dec 100644 --- a/src/msg/storage/postgres.py +++ b/src/msg/storage/postgres.py @@ -540,7 +540,10 @@ async def enqueue(self, job): class PostgresMetadataStore: """One connection per transaction; no mutable process-local authority state.""" - def __init__(self, dsn: str, *, signal=None, initialize: bool = True): + def __init__(self, dsn: str, *, signal=None, initialize: bool = True, + read_only: bool = False): + require(not read_only or (not initialize and signal is None), 'read_only_role') + self.read_only = read_only self.dsn = dsn self.signal = signal self._current = contextvars.ContextVar('msg_pg_transaction_' + uuid.uuid4().hex, default=None) @@ -575,6 +578,7 @@ async def _connect_async(self): @asynccontextmanager async def transaction(self, *, write): + require(not self.read_only or not write, 'read_only_role') existing = self._current.get() if existing is not None: existing.check(write) @@ -651,6 +655,7 @@ def backup(self, destination: Path): The caller holds the store's write transaction while copying content trees. Credentials travel via the child environment, never command arguments. """ + require(not self.read_only, 'read_only_role') destination = Path(destination) fields = conninfo_to_dict(self.dsn) password = fields.pop('password', None) From b1ab06c723a3bacd6c1a4ee8abddb54cbf4be31c Mon Sep 17 00:00:00 2001 From: msg maintenance CI <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 29 Sep 2026 09:26:46 +0000 Subject: [PATCH 3/4] fix: import the PostgreSQL read-only role guard --- src/msg/storage/postgres.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/msg/storage/postgres.py b/src/msg/storage/postgres.py index 62d96dec..c1308dfc 100644 --- a/src/msg/storage/postgres.py +++ b/src/msg/storage/postgres.py @@ -22,7 +22,7 @@ from psycopg import sql from psycopg.conninfo import conninfo_to_dict, make_conninfo -from msg.core.errors import Failure +from msg.core.errors import Failure, require from msg.core.query import QueryResult, SqlParameters from msg.storage.query import SessionQueryResult from msg.storage.session import RelationalSession From eafea15af0b8ff26c0208d5c0fcc039b4d32fb56 Mon Sep 17 00:00:00 2001 From: LIghtJUNction Date: Tue, 29 Sep 2026 17:33:41 +0800 Subject: [PATCH 4/4] test: exercise hosting under an unprivileged UID and live authorization changes --- .github/workflows/ci.yml | 2 +- .github/workflows/hosting-readonly.yml | 2 +- docs/HOSTING_READONLY_PROGRESS.md | 24 +- src/msg/hosting_runtime.py | 5 +- tests/test_hosting_readonly_isolation.py | 273 +++++++++++++++++++++++ 5 files changed, 293 insertions(+), 13 deletions(-) create mode 100644 tests/test_hosting_readonly_isolation.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 22b3ed01..52d98886 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -60,7 +60,7 @@ jobs: - name: Install required recovery and ingress test tools run: | sudo apt-get update -qq - sudo apt-get install -y -qq age nginx openssl git-lfs + sudo apt-get install -y -qq age nginx openssl git-lfs acl age --version nginx -v openssl version diff --git a/.github/workflows/hosting-readonly.yml b/.github/workflows/hosting-readonly.yml index 3886ec31..a5a272eb 100644 --- a/.github/workflows/hosting-readonly.yml +++ b/.github/workflows/hosting-readonly.yml @@ -65,7 +65,7 @@ jobs: - name: Install isolated prerequisites run: | sudo apt-get update -qq - sudo apt-get install -y -qq age git-lfs + sudo apt-get install -y -qq age git-lfs acl python -m pip install -e '.[dev]' - name: Verify read-only role and existing hosting contracts run: >- diff --git a/docs/HOSTING_READONLY_PROGRESS.md b/docs/HOSTING_READONLY_PROGRESS.md index e2a739b3..c8574231 100644 --- a/docs/HOSTING_READONLY_PROGRESS.md +++ b/docs/HOSTING_READONLY_PROGRESS.md @@ -1,19 +1,25 @@ # Hosting 只读角色推进笔记 -Refs #161 #80 #83 #85。分支 `fix/hosting-readonly-161-20260929`;基线 main `261c8816cd581deaf69fc0c810b8fa6dad5ead5e` / tree `6171f6041ffd0addaf72eadd35acf562f478281f`。协调登记 #83 comment5887036549。 +Refs #161 #80 #83 #85;PR #174,分支 `fix/hosting-readonly-161-20260929`。基线 main `261c8816cd581deaf69fc0c810b8fa6dad5ead5e` / tree `6171f6041ffd0addaf72eadd35acf562f478281f`。协调登记 #83 comment5887036549。 -## 边界 +## 范围与实现 -主责 hosting 装配、必要只读存储模式、部署模板与测试。daemon 仅窄接线,Application 仅抽取共用登记装配,不动 #172 凭据交付、#167 执行器/存储会话语义。#168 市场、#170 Git、#171 客户端、#155 恢复的分支不覆盖;共享文件最终组合仍须串行审查。 +独立 HostingRuntime 只读装配、必要 PostgreSQL/GitContentStore 只读模式、部署模板与测试。共用原注册表安装顺序,保留契约元数据,同时去掉 operation 和 manifest 上的可执行回调;仍用原认证、当前授权与托管读取,不构造 Application/执行器/签名器或读取服务私钥。启动只核验现有 schema、root、release 来源和恢复隔离,不迁移、不补目录、不发布规则。daemon 只窄接线,Application 仅抽共用装配,不动凭据一次性交付和 executor/batch 政策。 -## 本次提交:测试先行 +#168 市场、#170 Git、#171 客户端、#172 凭据、#155 恢复源分支不覆盖。最终串行组合必须刷新 main,专项不能替代完整四分片/node-ID/conformance/wheel/sdist。 -新增真正 PostgreSQL 只读角色 fixture、无服务密钥启动/禁止规则同步、Registry 契约元数据与不可执行回调、数据库写入口/真实 SQL 拒绝、Git 读取/写入拒绝、错误 writer DSN、缺规则不修复、独立 OS 身份模板检查。仅测试和工作流,尚无生产实现;预期红基线须读取实际云端报告后记录,不能提前记为已验证。 +## 已核验云端证据 -`Hosting read-only role` push/PR 工作流运行 Python 3.15、真实 PostgreSQL/Git/age,保留现有 hosting/CLI 回归,上传 source.json 与 JUnit。项目代码不在本地执行;本地只做文本、差异、源码和下载产物检查。完整四分片/node-ID/conformance/wheel/sdist 门槛不被专项替代。 +测试先行头 `00ba8f79cb25316bc33690f5fde96e2546176b21`,合成 checkout `03279378a3f1e003ae1fe7fce7681f4e6efc923a`,tree `4175b4a83312d87b1d3617c6caa8775c76cc8095`。run36547405429 / artifact11023705221,Python3.15.0rc2、attempt1,JUnit **7 failed /6 passed /0 errors /0 skipped**。新增7项因缺少只读能力准确失败,现有6项全过。下载 ZIP SHA256 `a13f0ceca4d7fab74d83de71e8254daf52560e4b96bcfa21a1e0de56dad0296d` 与提供方一致;逐 node 与 source.json 已核对。证据评论 #174 comment5887238998。 -## 后续 +首版实现 `0f0e13d1fe7d152a9034b4028ae2e1e7e2d06966` / tree `6c34817242491b2631899fd3cce7dba69d7915d8` 的 run36548624138 出现12个 fixture error:PostgresMetadataStore 遗漏 require 导入。此轮不作为功能通过或红基线证据。补丁 `b1ab06c723a3bacd6c1a4ee8abddb54cbf4be31c` / tree `667462eeb5968a8728d57960e988f7972fcf3fbb` 已修正导入并触发 run36549575056;最终结论仍待真实报告。 -实现仅认证/当前授权/读取的运行上下文;不加载在线/回执/token 私钥,不持有 Application/写执行器,不初始化数据库/目录或发布规则。数据库专用 SELECT 角色与 OS 只读权限分别验证;私有 preview、当前撤权、HEAD/Range/暖缓存、恢复 quarantine 继续拒绝越权。 +## 本次补充验收 -没有生产部署、权限变更、Root/PIN、资金、真实外发、备份销毁或恢复放行。#64/#65/#84 等现场材料缺失不通过本项关闭。 +增加真实签名 private preview/ACL撤回(含 HEAD、Range、ETag)、错误 Host、不暴露 schema、持久/文件/悬空符号链接 quarantine、缺 public trust/schema 不修复、DB额外写权限/SET ROLE/SECURITY DEFINER 拒绝。 + +真实 UID65534 子进程不能读 fixture online.key 或写业务文件,但必须读公开页面与后续新发布的文本和二进制 preview。只在发布前配置内容组权限,发布后禁止靠 chmod 修复用例。已发现原 durable_write/mkstemp 的0600可能使新文件不可读,将以云端结果定位;不能全局改 durable_write 默认值(该工具也写秘密),内容组共享需显式配置。 + +工作流补齐 acl 依赖,完整 CI 只增加此工具,不删测试、不改四分片或 gate。项目执行全部在 Actions;本地只做源码文本/静态审查及下载证据读取。辅助 git 补丁生成仅独立 ci 分支,产品提交不含辅助脚本。 + +尚未合并或关闭 #161。没有生产部署、权限变更、Root/PIN、资金、真实外发、备份销毁或恢复 promotion。#64/#65/#84 等现场材料缺失不通过本项关闭。 diff --git a/src/msg/hosting_runtime.py b/src/msg/hosting_runtime.py index 67c90df6..cfc23dba 100644 --- a/src/msg/hosting_runtime.py +++ b/src/msg/hosting_runtime.py @@ -66,9 +66,10 @@ def _require_readonly_database(tx): "AND c.relkind IN ('r','p','v','m','f') " "AND has_table_privilege(c.oid, 'INSERT,UPDATE,DELETE,TRUNCATE,REFERENCES,TRIGGER') " "LIMIT 1") is None, 'hosting_database_role_not_readonly') + # CASE keeps the planner from calling the sequence-only function on indexes. require(tx.one("SELECT 1 FROM pg_class c JOIN pg_namespace n ON n.oid=c.relnamespace " - "WHERE n.nspname NOT LIKE 'pg_%' AND c.relkind='S' " - "AND has_sequence_privilege(c.oid, 'USAGE,UPDATE') LIMIT 1") is None, + "WHERE n.nspname NOT LIKE 'pg_%' AND CASE WHEN c.relkind='S' " + "THEN has_sequence_privilege(c.oid, 'USAGE,UPDATE') ELSE FALSE END LIMIT 1") is None, 'hosting_database_role_not_readonly') require(tx.one("SELECT 1 FROM pg_proc p JOIN pg_namespace n ON n.oid=p.pronamespace " "WHERE n.nspname NOT LIKE 'pg_%' AND n.nspname <> 'information_schema' " diff --git a/tests/test_hosting_readonly_isolation.py b/tests/test_hosting_readonly_isolation.py new file mode 100644 index 00000000..f6d600a8 --- /dev/null +++ b/tests/test_hosting_readonly_isolation.py @@ -0,0 +1,273 @@ +"""Current authorization, failure containment and real non-owner filesystem reads.""" +from datetime import timedelta +import hashlib +import json +import os +from pathlib import Path +import shutil +import subprocess +import sys +import tempfile + +import httpx +import psycopg +from psycopg import sql +import pytest + +from msg.core.codec import b64, canonical, wire +from msg.core.errors import Failure +from msg.core.requests import request_for +from test_hosting_readonly import reader_settings, runtime +from test_service import NOW, call, register + + +async def preview(app, name='readonly-preview', *, binary=False): + key, subject, _ = await register(app, name) + site = await call(app, 'hosting.create', {'parent': '/@'+name, 'name': 'web'}, + key=key, subject=subject) + assert site.status == 'ok', wire(site) + payload = b'\x00readonly-live-binary\xff' if binary else b'

private read-only preview

' + filename = 'asset.bin' if binary else 'index.html' + source = await call(app, 'content.file_put', {'parent': '/@'+name+'/files', + 'name': filename, 'data': b64(payload), + 'media_type': 'application/octet-stream' if binary else 'text/html'}, key=key, subject=subject) + assert source.status == 'ok', wire(source) + candidate = await call(app, 'hosting.preview', {'id': site.resources[0].id, + 'entries': [{'path': filename, 'source': wire(source.resources[0])}]}, + key=key, subject=subject, expected=((site.resources[0].id, site.data['generation']),)) + assert candidate.status == 'ok', wire(candidate) + packet = request_for('discovery.raw', {'id': candidate.resources[0].id}, app.settings.service_url, + signer=key, subject=subject, expires_at=NOW+timedelta(seconds=120)) + return key, subject, candidate, payload, { + 'path': f'/@{name}/web/_preview/{candidate.resources[0].id}/{filename}', + 'header': b64(canonical(wire(packet))), 'payload': b64(payload)} + + +@pytest.mark.asyncio +async def test_preview_rechecks_acl_before_head_range_and_cached_response(installed, reader_settings): + app, _ = installed + key, subject, candidate, payload, request = await preview(app) + service = runtime(reader_settings, app.clock) + await service.load() + try: + from msg.extensions.hosting import hosting_app + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=hosting_app(service)), + base_url='http://testserver') as http: + path = request['path'] + headers = {'X-Msg-Request': request['header']} + assert (await http.get(path)).status_code == 403 + accepted = await http.get(path, headers=headers) + assert accepted.status_code == 200 and accepted.content == payload + assert 'allow-same-origin' not in accepted.headers['content-security-policy'] + assert (await http.get('/-/schema')).status_code == 404 + assert (await http.get(path, headers={**headers, 'Host': 'other.example'})).status_code == 404 + denied = await call(app, 'content.chmod', {'id': candidate.resources[0].id, 'mode': '0000'}, + key=key, subject=subject, expected=((candidate.resources[0].id, candidate.data['generation']),)) + assert denied.status == 'ok', wire(denied) + for method, extra in (('GET', {}), ('HEAD', {}), ('GET', {'Range': 'bytes=0-7'}), + ('GET', {'If-None-Match': accepted.headers['etag']})): + response = await http.request(method, path, headers={**headers, **extra}) + assert response.status_code == 403 + assert payload not in response.content + finally: + await service.close() + + +@pytest.mark.asyncio +@pytest.mark.parametrize('gate', ['database', 'marker', 'marker_symlink']) +async def test_quarantine_revokes_loaded_hosting_even_for_cached_requests(installed, reader_settings, gate): + app, _ = installed + service = runtime(reader_settings, app.clock) + await service.load() + marker = reader_settings.config_dir/'recovery-drill.json' + try: + from msg.extensions.hosting import hosting_app + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=hosting_app(service)), + base_url='http://testserver') as http: + original = await http.get('/@root/web/index.html') + assert original.status_code == 200 + if gate == 'database': + async with app.metadata.transaction(write=True) as tx: + tx.set_setting('recovery_quarantine', {'version': 1, 'test': True}) + elif gate == 'marker': + marker.write_text('{}') + else: + marker.symlink_to(marker.with_name('missing-marker-target')) + for method in ('GET', 'HEAD'): + response = await http.request(method, '/@root/web/index.html', + headers={'If-None-Match': original.headers['etag']}) + assert response.status_code not in (200, 206, 304) + assert original.content not in response.content + fresh = runtime(reader_settings, app.clock) + try: + with pytest.raises(Failure, match='recovery_quarantined'): + await fresh.load() + finally: + await fresh.close() + finally: + marker.unlink(missing_ok=True) + await service.close() + + +@pytest.mark.asyncio +async def test_missing_trust_and_schema_fail_without_initialization(installed, reader_settings): + app, _ = installed + trust = reader_settings.trust_file + hidden = trust.with_name('root.test-hidden') + trust.rename(hidden) + service = runtime(reader_settings, app.clock) + try: + with pytest.raises(Failure, match='root_not_initialized'): + await service.load() + assert not trust.exists() + finally: + hidden.rename(trust) + await service.close() + with psycopg.connect(app.settings.server.postgres_dsn, autocommit=True) as conn: + conn.execute('DROP TABLE schema_version') + service = runtime(reader_settings, app.clock) + try: + with pytest.raises(Failure, match='hosting_installation_not_ready'): + await service.load() + finally: + await service.close() + with psycopg.connect(app.settings.server.postgres_dsn) as conn: + assert conn.execute("SELECT to_regclass('public.schema_version')").fetchone() == (None,) + + +@pytest.mark.asyncio +@pytest.mark.parametrize('extra', ['write', 'membership', 'definer']) +async def test_effective_database_privilege_escalations_fail_startup(installed, reader_settings, extra): + app, _ = installed + with psycopg.connect(reader_settings.server.postgres_dsn) as conn: + reader = conn.info.user + with psycopg.connect(app.settings.server.postgres_dsn, autocommit=True) as conn: + if extra == 'write': + conn.execute(sql.SQL('GRANT UPDATE ON settings TO {}').format(sql.Identifier(reader))) + elif extra == 'membership': + conn.execute(sql.SQL('GRANT {} TO {}').format(sql.Identifier(conn.info.user), sql.Identifier(reader))) + else: + conn.execute('CREATE FUNCTION hosting_test_definer() RETURNS integer LANGUAGE sql ' + "SECURITY DEFINER AS 'SELECT 1'") + service = runtime(reader_settings, app.clock) + try: + with pytest.raises(Failure, match='hosting_database_role_not_readonly'): + await service.load() + finally: + await service.close() + + +READ_PROCESS = r''' +import asyncio, json, os, sys +from datetime import UTC, datetime +from pathlib import Path +import httpx +from msg.config import load_settings +from msg.core.codec import unb64 +from msg.hosting_runtime import HostingRuntime +from msg.extensions.hosting import hosting_app + +async def main(): + assert os.geteuid() == 65534, os.geteuid() + settings = load_settings(Path(sys.argv[1])) + private = Path(sys.argv[2]) + try: + private.read_bytes() + except PermissionError: + pass + else: + raise AssertionError('reader can read service signing material') + try: + (settings.server.content_dir/'forbidden-write').write_bytes(b'no') + except PermissionError: + pass + else: + raise AssertionError('reader can write business files') + service = HostingRuntime(settings, clock=lambda: datetime(2026, 9, 27, tzinfo=UTC)) + await service.load() + try: + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=hosting_app(service)), + base_url='http://testserver') as http: + assert (await http.get('/@root/web/index.html')).status_code == 200 + for request in json.loads(Path(sys.argv[3]).read_text()): + assert (await http.get(request['path'])).status_code == 403 + response = await http.get(request['path'], headers={'X-Msg-Request': request['header']}) + assert response.status_code == 200, response.status_code + assert response.content == unb64(request['payload']) + print(json.dumps({'uid': os.geteuid(), 'private_key_denied': True, + 'business_write_denied': True, 'live_previews': 2})) + finally: + await service.close() +asyncio.run(main()) +''' + + +@pytest.mark.asyncio +async def test_real_non_owner_reads_new_text_and_binary_publications_without_keys(installed, reader_settings): + # CI provides sudo/setfacl. This is a real UID boundary in a disposable install. + assert shutil.which('sudo') and shutil.which('setfacl'), 'Linux isolation prerequisites missing' + app, _ = installed + base = Path(tempfile.mkdtemp(prefix='msg-hosting-reader-')) + changed_ancestors = [] + try: + package = Path(__file__).resolve().parents[1]/'src'/'msg' + shutil.copytree(package, base/'src'/'msg') + from msg.config import write_example + config = write_example(base/'etc', base/'unused', 'http://testserver', + postgres_dsn=reader_settings.server.postgres_dsn) + config_file = config.config_dir/'msgd.toml' + text = config_file.read_text() + for previous, actual in ((config.server.content_dir, app.contents.path), + (config.server.blob_dir, app.contents.binary), + (config.server.staging_dir, app.contents.staging)): + text = text.replace(str(previous), str(actual)) + config_file.write_text(text) + config.trust_file.parent.mkdir(parents=True) + shutil.copyfile(reader_settings.trust_file, config.trust_file) + # Provision existing content first; NEW publications get no ACL/chmod repair. + for root in (app.contents.path, app.contents.binary): + for parent in root.parents: + if parent == Path('/tmp') or parent == Path('/'): + break + old = parent.stat().st_mode & 0o7777 + if not old & 0o001: + changed_ancestors.append((parent, old)) + parent.chmod(old | 0o001) + for path in [root, *root.rglob('*')]: + path.chmod(0o2750 if path.is_dir() else 0o640) + for directory in [root, *(p for p in root.rglob('*') if p.is_dir())]: + subprocess.run(['setfacl', '-m', 'd:u::rwx,d:g::r-x,d:o::---', str(directory)], + check=True, capture_output=True) + subprocess.run(['git', '--git-dir', str(app.contents.repo), 'config', 'core.sharedRepository', '0640'], + check=True, capture_output=True) + first = await preview(app, 'live-text-reader') + second = await preview(app, 'live-binary-reader', binary=True) + requests = base/'requests.json' + requests.write_text(json.dumps([first[-1], second[-1]])) + script = base/'reader.py' + script.write_text(READ_PROCESS) + for path in [base, *base.rglob('*')]: + path.chmod(0o555 if path.is_dir() else 0o444) + before = {str(path): hashlib.sha256(path.read_bytes()).hexdigest() + for root in (app.contents.path, app.contents.binary) + for path in root.rglob('*') if path.is_file()} + result = subprocess.run(['sudo', '-n', '-u', '#65534', '-g', '#'+str(os.getgid()), '--', + 'env', 'PYTHONDONTWRITEBYTECODE=1', 'PYTHONPATH='+str(base/'src'), + sys.executable, str(script), str(config.config_dir), + str(app.settings.service_keys/'online.key'), str(requests)], + capture_output=True, text=True, timeout=120) + assert result.returncode == 0, result.stderr[-6000:] + report = json.loads(result.stdout) + assert report == {'uid': 65534, 'private_key_denied': True, + 'business_write_denied': True, 'live_previews': 2} + assert before == {str(path): hashlib.sha256(path.read_bytes()).hexdigest() + for root in (app.contents.path, app.contents.binary) + for path in root.rglob('*') if path.is_file()} + assert not (app.contents.path/'forbidden-write').exists() + finally: + for path in [base, *base.rglob('*')]: + if path.is_dir(): + path.chmod(0o700) + shutil.rmtree(base) + for parent, mode in reversed(changed_ancestors): + parent.chmod(mode)