diff --git a/.github/workflows/subject-view-contracts.yml b/.github/workflows/subject-view-contracts.yml new file mode 100644 index 00000000..91617d6e --- /dev/null +++ b/.github/workflows/subject-view-contracts.yml @@ -0,0 +1,70 @@ +name: Subject view contracts +on: + push: + branches: [work/remaining-contracts-20260930] + pull_request: + paths: + - src/msg/transports/http_routes.py + - src/msg/transports/subject_views.py + - tests/test_subject_view_effect_order.py + - .github/workflows/subject-view-contracts.yml + workflow_dispatch: +permissions: + contents: read +concurrency: + group: subject-view-contracts-${{ github.head_ref || github.ref_name }} + cancel-in-progress: true +jobs: + contracts: + runs-on: ubuntu-latest + timeout-minutes: 15 + 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 5s --health-timeout 5s --health-retries 10 + 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 + - name: Install locked contracts environment + run: | + sudo apt-get update -qq + sudo env NEEDRESTART_MODE=l apt-get install -y -qq age + python -m pip install uv==0.12.20 + uv sync --locked --extra dev --python "$(command -v python)" + echo "$GITHUB_WORKSPACE/.venv/bin" >> "$GITHUB_PATH" + uv pip check + - name: Record exact source + run: | + mkdir -p artifacts + python - <<'PY' + import json, os, platform, subprocess + from pathlib import Path + git = lambda *args: subprocess.check_output(['git', *args], text=True).strip() + Path('artifacts/source.json').write_text(json.dumps({ + 'commit': git('rev-parse', 'HEAD'), 'tree': git('rev-parse', 'HEAD^{tree}'), + 'run_id': os.environ['GITHUB_RUN_ID'], 'python': platform.python_version(), + }, indent=2) + '\n') + PY + - name: Require subject read boundaries and permanent aliases + run: | + python -m pytest tests/test_subject_view_effect_order.py tests/test_subject_read_aliases.py tests/test_receipt_effect_order.py tests/test_http_effect_order.py tests/test_route_effect_matrix.py tests/test_hosting_read_permissions.py --junitxml=artifacts/subject-views.xml + - uses: actions/upload-artifact@v7.0.1 + if: always() + with: + name: subject-view-contracts + path: artifacts/ + if-no-files-found: error diff --git a/src/msg/transports/http_routes.py b/src/msg/transports/http_routes.py index 74d687a7..de81cb19 100644 --- a/src/msg/transports/http_routes.py +++ b/src/msg/transports/http_routes.py @@ -39,6 +39,14 @@ from msg.transports.mcp import PROTOCOL_VERSION, SUPPORTED_VERSIONS, MCPServer from msg.transports.packet import decode_packet, path_packet, require_url_safe_packet from msg.transports.read_tree_path import decode_read_tree_path +from msg.transports.subject_views import ( + CERTIFICATE_COLLECTION_VIEWS, + SUBJECT_COLLABORATION_VIEWS, + SUBJECT_KEY_ALIASES as SUBJECT_KEY_ALIASES, + SUBJECT_OPERATION_ALIASES as SUBJECT_OPERATION_ALIASES, + SUBJECT_RESOURCE_ALIASES as SUBJECT_RESOURCE_ALIASES, + subject_view_operation, +) from msg.transports.url_safety import require_matching_host, require_safe_request_target HOME_LOGO = files('msg.data').joinpath('logo.svg').read_text(encoding='utf-8') @@ -70,34 +78,6 @@ 'Content-Security-Policy': "default-src 'none'; style-src 'unsafe-inline'; img-src 'self'; base-uri 'none'; " "form-action 'none'; frame-ancestors 'none'; sandbox", } -SUBJECT_RESOURCE_ALIASES = { - 'cert': 'certificates', - 'certificates': 'certificates', - 'ks': 'keystore', - 'keystore': 'keystore', - 'ssh': 'keys', - 'ssh-keys': 'keys', -} -SUBJECT_KEY_ALIASES = { - 'pk': ('identity.identity_key_get', 'pk'), - 'pubkey': ('identity.identity_key_get', 'pk'), - 'k': ('identity.identity_key_list', 'k'), - 'keys': ('identity.identity_key_list', 'k'), - 'ek': ('identity.encryption_key_get', 'ek'), - 'encryption-key': ('identity.encryption_key_get', 'ek'), - 'e': ('identity.encryption_key_list', 'e'), - 'encryption-keys': ('identity.encryption_key_list', 'e'), -} -SUBJECT_OPERATION_ALIASES = { - 'ach': 'achievement.list', - 'achievements': 'achievement.list', - 'in': 'communication.inbox', - 'inbox': 'communication.inbox', - 'out': 'communication.outbox', - 'outbox': 'communication.outbox', - 'dm': 'communication.dm_list', - 'following': 'communication.following', -} SEARCH_V2_SEGMENTS = { 'scope': 's', 'terms': 't', @@ -218,6 +198,11 @@ def classify_route(path, method, registry): return operation_route(registry.operation('git.lfs_read_batch')) if re.fullmatch(r'/[@&][^/]+/[^/]+\.git/(?:info/refs|git-upload-pack|HEAD)', path): return operation_route(registry.operation('git.refs')) + if path.startswith(('/@', '/&')): + operation = subject_view_operation(path) + if operation is None: + operation = 'discovery.raw' if parse_view(path)[1] == 'raw' else 'discovery.get' + return operation_route(registry.operation(operation)) return RouteSpec('read', RouteEffect.PURE_READ) @@ -732,13 +717,12 @@ async def dispatch(request: Request): and 'x-method-override' not in request.headers, 'method_not_allowed', ) - receipt_alias = re.fullmatch(r'/@[^/]+/receipts(?:/(.*))?', request.url.path) - if receipt_alias and request.method in {'GET', 'HEAD'}: - # Hosting probes this namespace before the ordinary router. Reject - # changed receipt effects before that probe can query a subject. - tail = (receipt_alias.group(1) or '').strip('/') - operation = 'communication.receipt_' + ('list' if tail in {'', 'json'} else 'get') - require(service.registry.operation(operation).effect == 'read', 'effect_mismatch') + subject_route = None + if request.url.path.startswith(('/@', '/&')) and request.method in {'GET', 'HEAD'}: + # Hosting probes this namespace before the ordinary router. + # Fence the selected operation before any business query. + subject_route = classify_route(request.url.path, request.method, service.registry) + require(subject_route.effect == RouteEffect.PURE_READ, 'effect_mismatch') if request.url.path.startswith(('/@', '/&')): from msg.extensions.hosting import serve_hosted @@ -1927,15 +1911,7 @@ async def dispatch(request: Request): ), 'not_found', ) - operation = ( - 'orders.list' - if listing - else 'orders.payment' - if len(parts) == 2 and parts[1] == '_payment' - else 'delivery.get' - if len(parts) == 2 - else 'orders.get' - ) + operation = subject_route.name pairs = request.query_params.multi_items() require( len(pairs) == len({key for key, _ in pairs}), 'duplicate_query_parameter' @@ -1988,7 +1964,7 @@ async def dispatch(request: Request): if name in {'bal', 'balance', 'ledger'}: require(remainder in {None, '/', '/json'}, 'not_found') require(b'%' not in raw_path, 'not_found') - operation = 'money.ledger' if name == 'ledger' else 'money.balance' + operation = subject_route.name pairs = request.query_params.multi_items() require( len(pairs) == len({key for key, _ in pairs}), 'duplicate_query_parameter' @@ -2036,15 +2012,7 @@ async def dispatch(request: Request): media_type='application/json', headers=headers, ) - if name in { - 'handoffs', - 'leases', - 'requests', - 'offers', - 'checkpoints', - 'proposals', - 'watches', - }: + if name in SUBJECT_COLLABORATION_VIEWS: require(request.method in {'GET', 'HEAD'}, 'method_not_allowed') require( raw_path.decode('utf-8') == request.url.path and b'%' not in raw_path, @@ -2057,8 +2025,7 @@ async def dispatch(request: Request): re.fullmatch(r'[A-Za-z0-9_.:-]{1,160}(?:/json)?', tail) is not None, 'not_found', ) - singular = 'watch' if name == 'watches' else name[:-1] - operation = 'communication.' + singular + ('_list' if listing else '_get') + operation = subject_route.name pairs = request.query_params.multi_items() require( len(pairs) == len({key for key, _ in pairs}), 'duplicate_query_parameter' @@ -2122,7 +2089,7 @@ async def dispatch(request: Request): re.fullmatch(r'[A-Za-z0-9_.:-]{1,128}(?:/json)?', tail) is not None, 'not_found', ) - operation = 'communication.receipt_' + ('list' if listing else 'get') + operation = subject_route.name pairs = request.query_params.multi_items() require( len(pairs) == len({key for key, _ in pairs}), 'duplicate_query_parameter' @@ -2180,13 +2147,10 @@ async def dispatch(request: Request): media_type='application/json', headers=headers, ) - certificate_detail = name in {'cert', 'certificates'} and remainder not in { - None, - '/', - '/json', - '/meta', - '/history', - } + certificate_detail = ( + name in {'cert', 'certificates'} + and remainder not in CERTIFICATE_COLLECTION_VIEWS + ) if ( name in SUBJECT_KEY_ALIASES or name in SUBJECT_OPERATION_ALIASES @@ -2204,16 +2168,15 @@ async def dispatch(request: Request): async with service.metadata.transaction(write=False) as tx: subject_id = await tx.resolve('/@' + handle) require((await tx.resource(subject_id)).type == 'user', 'not_found') + operation = subject_route.name if certificate_detail: tail = remainder.strip('/') require( re.fullmatch(r'[A-Za-z0-9_.:-]{1,160}(?:/json)?', tail) is not None, 'not_found', ) - operation = 'cert.get' args = {'id': tail.removesuffix('/json')} elif key_alias: - operation, _canonical = SUBJECT_KEY_ALIASES[name] tail = (remainder or '').strip('/') if tail == 'json': tail = '' @@ -2223,12 +2186,10 @@ async def dispatch(request: Request): 'not_found', ) key_id = tail.removesuffix('/json') - operation = operation.replace('_list', '_get') args = {'subject_id': subject_id} if tail: args['key_id'] = key_id else: - operation = SUBJECT_OPERATION_ALIASES[name] args = {'subject_id': subject_id} if operation == 'achievement.list' else {} if operation in { 'communication.inbox', diff --git a/src/msg/transports/subject_views.py b/src/msg/transports/subject_views.py new file mode 100644 index 00000000..bc284909 --- /dev/null +++ b/src/msg/transports/subject_views.py @@ -0,0 +1,76 @@ +"""Pure operation selection for published subject views and permanent aliases.""" + +import re + +SUBJECT_RESOURCE_ALIASES = { + 'cert': 'certificates', + 'certificates': 'certificates', + 'ks': 'keystore', + 'keystore': 'keystore', + 'ssh': 'keys', + 'ssh-keys': 'keys', +} +SUBJECT_KEY_ALIASES = { + 'pk': ('identity.identity_key_get', 'pk'), + 'pubkey': ('identity.identity_key_get', 'pk'), + 'k': ('identity.identity_key_list', 'k'), + 'keys': ('identity.identity_key_list', 'k'), + 'ek': ('identity.encryption_key_get', 'ek'), + 'encryption-key': ('identity.encryption_key_get', 'ek'), + 'e': ('identity.encryption_key_list', 'e'), + 'encryption-keys': ('identity.encryption_key_list', 'e'), +} +SUBJECT_OPERATION_ALIASES = { + 'ach': 'achievement.list', + 'achievements': 'achievement.list', + 'in': 'communication.inbox', + 'inbox': 'communication.inbox', + 'out': 'communication.outbox', + 'outbox': 'communication.outbox', + 'dm': 'communication.dm_list', + 'following': 'communication.following', +} +SUBJECT_COLLABORATION_VIEWS = frozenset({ + 'handoffs', + 'leases', + 'requests', + 'offers', + 'checkpoints', + 'proposals', + 'watches', +}) +CERTIFICATE_COLLECTION_VIEWS = frozenset({None, '/', '/json', '/meta', '/history'}) + + +def subject_view_operation(path): + """Select metadata without resolving a subject, resource or hosting site. + + The router validates path spelling and arguments after the effect fence. + Both the fence and execution use this same selection. + """ + match = re.fullmatch(r'/@([^/]+)/([^/]+)(/.*)?', path) + if match is None: + return None + _, name, remainder = match.groups() + tail = (remainder or '').strip('/') + if name == 'orders': + tail = tail.removesuffix('/json') + if tail in {'', 'json'}: + return 'orders.list' + parts = tail.split('/') + if len(parts) == 2: + return 'orders.payment' if parts[1] == '_payment' else 'delivery.get' + return 'orders.get' + if name in {'bal', 'balance', 'ledger'}: + return 'money.ledger' if name == 'ledger' else 'money.balance' + if name in SUBJECT_COLLABORATION_VIEWS: + singular = 'watch' if name == 'watches' else name[:-1] + return 'communication.' + singular + ('_list' if tail in {'', 'json'} else '_get') + if name == 'receipts': + return 'communication.receipt_' + ('list' if tail in {'', 'json'} else 'get') + if name in {'cert', 'certificates'} and remainder not in CERTIFICATE_COLLECTION_VIEWS: + return 'cert.get' + if name in SUBJECT_KEY_ALIASES: + operation, _ = SUBJECT_KEY_ALIASES[name] + return operation.replace('_list', '_get') if tail not in {'', 'json'} else operation + return SUBJECT_OPERATION_ALIASES.get(name) diff --git a/tests/test_subject_view_effect_order.py b/tests/test_subject_view_effect_order.py new file mode 100644 index 00000000..ba2692ee --- /dev/null +++ b/tests/test_subject_view_effect_order.py @@ -0,0 +1,173 @@ +"""Every published subject read view fences effects before hosting or lookup.""" + +from dataclasses import replace +from unittest.mock import AsyncMock, Mock + +import httpx +import pytest +from read_only_evidence import readonly_evidence +from test_http_effect_order import runtime_check_only + +from msg.extensions import hosting +from msg.security.quarantine import RUNTIME_GENERATION +from msg.transports.http import create_app + +# Independent public URL inventory, including archived permanent aliases. +ORDER = 'ord_' + 'a' * 32 +VIEWS = ( + ('orders', 'orders.list'), + ('orders/json', 'orders.list'), + ('orders/' + ORDER, 'orders.get'), + ('orders/' + ORDER + '/_payment/json', 'orders.payment'), + ('orders/' + ORDER + '/_delivery', 'delivery.get'), + ('bal', 'money.balance'), + ('balance/json', 'money.balance'), + ('ledger', 'money.ledger'), + ('handoffs', 'communication.handoff_list'), + ('handoffs/item0/json', 'communication.handoff_get'), + ('leases', 'communication.lease_list'), + ('leases/item0', 'communication.lease_get'), + ('requests', 'communication.request_list'), + ('requests/item0', 'communication.request_get'), + ('offers', 'communication.offer_list'), + ('offers/item0', 'communication.offer_get'), + ('checkpoints', 'communication.checkpoint_list'), + ('checkpoints/item0', 'communication.checkpoint_get'), + ('proposals', 'communication.proposal_list'), + ('proposals/item0', 'communication.proposal_get'), + ('watches', 'communication.watch_list'), + ('watches/item0', 'communication.watch_get'), + ('pk', 'identity.identity_key_get'), + ('pubkey', 'identity.identity_key_get'), + ('k', 'identity.identity_key_list'), + ('keys', 'identity.identity_key_list'), + ('k/key0/json', 'identity.identity_key_get'), + ('keys/key0', 'identity.identity_key_get'), + ('ek', 'identity.encryption_key_get'), + ('encryption-key', 'identity.encryption_key_get'), + ('e', 'identity.encryption_key_list'), + ('encryption-keys', 'identity.encryption_key_list'), + ('e/key0', 'identity.encryption_key_get'), + ('encryption-keys/key0/json', 'identity.encryption_key_get'), + ('cert/cert0', 'cert.get'), + ('certificates/cert0/json', 'cert.get'), + ('ach', 'achievement.list'), + ('achievements', 'achievement.list'), + ('in', 'communication.inbox'), + ('inbox', 'communication.inbox'), + ('out', 'communication.outbox'), + ('outbox', 'communication.outbox'), + ('dm', 'communication.dm_list'), + ('following', 'communication.following'), + ('receipts', 'communication.receipt_list'), + ('receipts/json', 'communication.receipt_list'), + ('receipts/request0', 'communication.receipt_get'), + ('receipts/request0/json', 'communication.receipt_get'), + ('cert', 'discovery.get'), + ('certificates/json', 'discovery.get'), + ('cert/history', 'discovery.get'), + ('ssh', 'discovery.get'), + ('ssh-keys/json', 'discovery.get'), + ('ssh/key0/json', 'discovery.get'), + ('ssh-keys/key0', 'discovery.get'), + ('ks', 'discovery.get'), + ('keystore/json', 'discovery.get'), + ('ks/item0/raw', 'discovery.raw'), + ('keystore/item0/meta', 'discovery.get'), + ('ordinary-resource/json', 'discovery.get'), + ('ordinary-resource/raw', 'discovery.raw'), +) + + +@pytest.mark.parametrize('effect', ['transaction', 'external']) +@pytest.mark.parametrize('method', ['GET', 'HEAD']) +async def test_subject_views_reject_effects_before_any_business_probe( + installed, monkeypatch, effect, method +): + app, _ = installed + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=create_app(app)), base_url=app.settings.service_url + ) as http: + async with readonly_evidence(app, monkeypatch): + with monkeypatch.context() as patch: + for operation in {operation for _, operation in VIEWS}: + key = (operation, 1) + assert app.registry._operations[key].effect == 'read', operation + patch.setitem( + app.registry._operations, + key, + replace(app.registry._operations[key], effect=effect), + ) + probe = AsyncMock(side_effect=AssertionError('effect gate probed hosting')) + patch.setattr(hosting, 'serve_hosted', probe) + async with runtime_check_only(app, patch) as runtime: + for suffix, _ in VIEWS: + for handle in ('root', 'missing-subject'): + path = '/@' + handle + '/' + suffix + response = await http.request(method, path) + assert response.status_code == 405, (path, response.text) + if method == 'GET': + assert response.json()['error']['code'] == 'effect_mismatch' + else: + assert response.content == b'' + assert runtime.await_count == len(VIEWS) * 2 + probe.assert_not_called() + + +@pytest.mark.parametrize('effect', ['transaction', 'external']) +@pytest.mark.parametrize('method', ['GET', 'HEAD']) +async def test_organization_views_reject_effects_before_hosting( + installed, monkeypatch, effect, method +): + app, _ = installed + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=create_app(app)), base_url=app.settings.service_url + ) as http: + async with readonly_evidence(app, monkeypatch): + with monkeypatch.context() as patch: + for operation in ('discovery.get', 'discovery.raw'): + key = (operation, 1) + patch.setitem( + app.registry._operations, + key, + replace(app.registry._operations[key], effect=effect), + ) + probe = AsyncMock(side_effect=AssertionError('effect gate probed hosting')) + patch.setattr(hosting, 'serve_hosted', probe) + async with runtime_check_only(app, patch) as runtime: + for path in ('/&root/site', '/&missing/site/json', '/&missing/site/raw'): + response = await http.request(method, path) + assert response.status_code == 405, (path, response.text) + if method == 'GET': + assert response.json()['error']['code'] == 'effect_mismatch' + else: + assert response.content == b'' + assert runtime.await_count == 3 + probe.assert_not_called() + + +@pytest.mark.parametrize('method', ['GET', 'HEAD']) +async def test_stale_runtime_precedes_all_subject_view_metadata(installed, monkeypatch, method): + app, _ = installed + async with app.metadata.transaction(write=True) as tx: + tx.set_setting(RUNTIME_GENERATION, 'subject-views-new-generation') + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=create_app(app)), base_url=app.settings.service_url + ) as http: + async with readonly_evidence(app, monkeypatch): + with monkeypatch.context() as patch: + lookup = Mock(side_effect=AssertionError('stale runtime read route metadata')) + probe = AsyncMock(side_effect=AssertionError('stale runtime probed hosting')) + patch.setattr(app.registry, 'operation', lookup) + patch.setattr(hosting, 'serve_hosted', probe) + async with runtime_check_only(app, patch) as runtime: + for suffix, _ in VIEWS: + response = await http.request(method, '/@root/' + suffix) + assert response.status_code == 503, (suffix, response.text) + if method == 'GET': + assert response.json()['error']['code'] == 'recovery_runtime_stale' + else: + assert response.content == b'' + assert runtime.await_count == len(VIEWS) + lookup.assert_not_called() + probe.assert_not_called()