msgctl 0.1.0a1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- msg/__init__.py +2 -0
- msg/admin/__init__.py +0 -0
- msg/admin/backup_retirement.py +56 -0
- msg/admin/backups.py +341 -0
- msg/admin/custodial_check.py +77 -0
- msg/admin/diagnostics.py +750 -0
- msg/admin/market.py +91 -0
- msg/admin/market_check.py +341 -0
- msg/admin/money.py +322 -0
- msg/admin/preflight.py +142 -0
- msg/admin/recovery_replay.py +281 -0
- msg/admin/restore_database.py +37 -0
- msg/admin/root.py +347 -0
- msg/admin/rotation.py +197 -0
- msg/admin/token_delivery_check.py +54 -0
- msg/admin/upgrade_check.py +58 -0
- msg/application.py +163 -0
- msg/bootstrap.py +305 -0
- msg/cli.py +537 -0
- msg/client.py +725 -0
- msg/client_certificates.py +98 -0
- msg/client_content.py +43 -0
- msg/client_custodial.py +206 -0
- msg/client_market.py +104 -0
- msg/client_recovery.py +186 -0
- msg/client_secrets.py +58 -0
- msg/client_tokens.py +99 -0
- msg/client_upgrade.py +188 -0
- msg/config.py +318 -0
- msg/constants.py +11 -0
- msg/core/__init__.py +0 -0
- msg/core/batching.py +23 -0
- msg/core/codec.py +253 -0
- msg/core/contracts.py +128 -0
- msg/core/cursors.py +68 -0
- msg/core/email_address.py +33 -0
- msg/core/errors.py +22 -0
- msg/core/events.py +6 -0
- msg/core/execution_ports.py +20 -0
- msg/core/executor.py +193 -0
- msg/core/models.py +517 -0
- msg/core/packet.py +71 -0
- msg/core/permissions.py +14 -0
- msg/core/query.py +44 -0
- msg/core/read_query.py +60 -0
- msg/core/registry.py +173 -0
- msg/core/requests.py +58 -0
- msg/core/schema_policy.py +28 -0
- msg/core/schemas.py +30 -0
- msg/core/tags.py +21 -0
- msg/core/template_dsl.py +94 -0
- msg/core/text_patch.py +283 -0
- msg/core/tool_execution.py +28 -0
- msg/core/transfer.py +183 -0
- msg/daemon.py +255 -0
- msg/data/__init__.py +0 -0
- msg/data/bootstrap.json +72 -0
- msg/data/favicon.png +0 -0
- msg/data/logo-dark.svg +5 -0
- msg/data/logo.svg +5 -0
- msg/data/recovery-checkpoint.example.json +1 -0
- msg/data/recovery-checkpoint.schema.json +118 -0
- msg/data/shortcodes.json +1 -0
- msg/data/system/AGENTS.md +6 -0
- msg/data/system/rules/_index.md +17 -0
- msg/data/system/rules/auth.md +6 -0
- msg/data/system/rules/files.md +6 -0
- msg/data/system/rules/identity.md +6 -0
- msg/data/system/rules/protocol.md +6 -0
- msg/data/system/rules/read-write.md +6 -0
- msg/data/system/rules/recovery.md +6 -0
- msg/data/system/rules/security.md +10 -0
- msg/data/system/rules/topics.md +6 -0
- msg/extensions/__init__.py +1 -0
- msg/extensions/hosting.py +259 -0
- msg/extensions/keystore.py +96 -0
- msg/extensions/repositories.py +769 -0
- msg/extensions/rss.py +67 -0
- msg/extensions/ssh.py +264 -0
- msg/extensions/ssh_git.py +300 -0
- msg/extensions/tools.py +150 -0
- msg/hosting_runtime.py +138 -0
- msg/market/__init__.py +1 -0
- msg/market/arbitration.py +481 -0
- msg/market/delivery.py +338 -0
- msg/market/delivery_notifications.py +110 -0
- msg/market/delivery_targets.py +76 -0
- msg/market/email.py +100 -0
- msg/market/escrow.py +345 -0
- msg/market/orders.py +218 -0
- msg/market/policy.py +147 -0
- msg/market/rationale.py +91 -0
- msg/market/references.py +19 -0
- msg/market/targets.py +157 -0
- msg/plugins/__init__.py +28 -0
- msg/plugins/achievements.py +316 -0
- msg/plugins/batch.py +32 -0
- msg/plugins/bounty.py +366 -0
- msg/plugins/collaboration.py +261 -0
- msg/plugins/common.py +217 -0
- msg/plugins/communication.py +853 -0
- msg/plugins/content.py +855 -0
- msg/plugins/custodial_lifecycle.py +224 -0
- msg/plugins/delivery.py +230 -0
- msg/plugins/discovery.py +1266 -0
- msg/plugins/discussion.py +162 -0
- msg/plugins/extensions.py +10 -0
- msg/plugins/following.py +78 -0
- msg/plugins/hosting_capacity.py +52 -0
- msg/plugins/identity.py +1654 -0
- msg/plugins/money.py +221 -0
- msg/plugins/offers.py +183 -0
- msg/plugins/orders.py +273 -0
- msg/plugins/recovery.py +376 -0
- msg/plugins/schemas.py +5 -0
- msg/plugins/sharing.py +300 -0
- msg/plugins/store.py +249 -0
- msg/plugins/system.py +75 -0
- msg/plugins/transfer.py +249 -0
- msg/py.typed +0 -0
- msg/security/__init__.py +0 -0
- msg/security/age_keys.py +112 -0
- msg/security/authentication.py +141 -0
- msg/security/authorization.py +300 -0
- msg/security/backup_retirement.py +135 -0
- msg/security/capabilities.py +157 -0
- msg/security/certificates.py +183 -0
- msg/security/crypto.py +102 -0
- msg/security/custodial_migration.py +292 -0
- msg/security/custody_history.py +146 -0
- msg/security/network.py +81 -0
- msg/security/policy.py +67 -0
- msg/security/quarantine.py +19 -0
- msg/security/root_files.py +67 -0
- msg/security/rotation_journal.py +60 -0
- msg/security/sealed_box.py +70 -0
- msg/security/sharing_policy.py +29 -0
- msg/security/token_delivery.py +93 -0
- msg/security/vault.py +186 -0
- msg/storage/__init__.py +0 -0
- msg/storage/capacity.py +100 -0
- msg/storage/custodial_migration.py +22 -0
- msg/storage/git.py +435 -0
- msg/storage/ledger_migration.py +307 -0
- msg/storage/market_migration.py +49 -0
- msg/storage/postgres.py +663 -0
- msg/storage/query.py +39 -0
- msg/storage/read_only.py +126 -0
- msg/storage/session.py +403 -0
- msg/storage/sqlite.py +360 -0
- msg/storage/topic_event_migration.py +26 -0
- msg/storage/valkey_bus.py +45 -0
- msg/transports/__init__.py +0 -0
- msg/transports/client.py +183 -0
- msg/transports/dictionary.py +387 -0
- msg/transports/graphql.py +75 -0
- msg/transports/http.py +72 -0
- msg/transports/http_routes.py +1577 -0
- msg/transports/mcp.py +127 -0
- msg/transports/packet.py +42 -0
- msg/transports/read_tree_path.py +65 -0
- msg/transports/stdio.py +36 -0
- msg/transports/url_safety.py +123 -0
- msg/tui.py +368 -0
- msg/workers/__init__.py +1 -0
- msg/workers/effects.py +406 -0
- msg/workers/leases.py +19 -0
- msg/workers/mail.py +63 -0
- msg/workers/maintenance.py +310 -0
- msg/workers/sandbox.py +74 -0
- msg/workers/sandbox_child.py +189 -0
- msg/workers/webhook.py +160 -0
- msgctl-0.1.0a1.dist-info/METADATA +96 -0
- msgctl-0.1.0a1.dist-info/RECORD +176 -0
- msgctl-0.1.0a1.dist-info/WHEEL +4 -0
- msgctl-0.1.0a1.dist-info/entry_points.txt +4 -0
msg/admin/diagnostics.py
ADDED
|
@@ -0,0 +1,750 @@
|
|
|
1
|
+
"""Read-only deployment inspection and disposable, real-operation self-tests."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
import asyncio
|
|
4
|
+
from contextlib import contextmanager
|
|
5
|
+
from datetime import UTC, datetime, timedelta
|
|
6
|
+
from importlib.util import find_spec
|
|
7
|
+
import os
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
import shutil
|
|
10
|
+
import psycopg
|
|
11
|
+
import stat
|
|
12
|
+
import subprocess
|
|
13
|
+
import sys
|
|
14
|
+
import tempfile
|
|
15
|
+
import uuid
|
|
16
|
+
from urllib.parse import quote
|
|
17
|
+
from psycopg import sql
|
|
18
|
+
|
|
19
|
+
from msg.core.codec import canonical, decode, digest, loads, unb64, wire, b64
|
|
20
|
+
from msg.core.errors import Failure, require
|
|
21
|
+
from msg.core.models import Certificate, Resource, ResourceRef, Subject, AuditEvent
|
|
22
|
+
from msg.constants import ROOT_SUBJECT
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@contextmanager
|
|
26
|
+
def temporary_postgres():
|
|
27
|
+
"""Run selftest against a new local cluster, never an installed database."""
|
|
28
|
+
configured=os.environ.get('MSG_TEST_POSTGRES_URL_TEMPLATE')
|
|
29
|
+
if configured:
|
|
30
|
+
name='msg_selftest_'+uuid.uuid4().hex
|
|
31
|
+
admin=configured.format(database='postgres')
|
|
32
|
+
with psycopg.connect(admin,autocommit=True) as connection:
|
|
33
|
+
connection.execute(sql.SQL('CREATE DATABASE {}').format(sql.Identifier(name)))
|
|
34
|
+
try:
|
|
35
|
+
yield configured.format(database=name)
|
|
36
|
+
finally:
|
|
37
|
+
with psycopg.connect(admin,autocommit=True) as connection:
|
|
38
|
+
connection.execute(sql.SQL('DROP DATABASE {} WITH (FORCE)').format(sql.Identifier(name)))
|
|
39
|
+
return
|
|
40
|
+
with tempfile.TemporaryDirectory(prefix='msg-selftest-pg-',dir='/tmp') as temporary:
|
|
41
|
+
root=Path(temporary)
|
|
42
|
+
data=root/'data'
|
|
43
|
+
socket=root/'socket';socket.mkdir()
|
|
44
|
+
subprocess.run(['initdb','-D',str(data),'-A','trust','--no-instructions'],
|
|
45
|
+
check=True,capture_output=True,text=True)
|
|
46
|
+
try:
|
|
47
|
+
subprocess.run(['pg_ctl','-D',str(data),'-l',str(root/'postgres.log'),
|
|
48
|
+
'-o',f"-k {socket} -h '' -p 5432",'-w','start'],
|
|
49
|
+
check=True,capture_output=True,text=True)
|
|
50
|
+
yield f'postgresql://localhost:5432/postgres?host={quote(str(socket),safe="")}'
|
|
51
|
+
finally:
|
|
52
|
+
subprocess.run(['pg_ctl','-D',str(data),'-m','immediate','-w','stop'],
|
|
53
|
+
check=False,capture_output=True,text=True)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class ReadOnlyStore:
|
|
57
|
+
"""Inspect PostgreSQL without schema creation or repair."""
|
|
58
|
+
def __init__(self,dsn):
|
|
59
|
+
self.dsn=dsn
|
|
60
|
+
|
|
61
|
+
async def inspect(self, callback):
|
|
62
|
+
from msg.storage.postgres import PostgresMetadataStore
|
|
63
|
+
store=PostgresMetadataStore(self.dsn,initialize=False)
|
|
64
|
+
async with store.transaction(write=False) as session:
|
|
65
|
+
return await callback(session)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def feature_results(features, observations, field):
|
|
69
|
+
"""Link manifest entries to checks actually run; never synthesize a pass."""
|
|
70
|
+
result={}
|
|
71
|
+
for feature in features:
|
|
72
|
+
feature_id=feature['feature_id']
|
|
73
|
+
name=feature[field]
|
|
74
|
+
if not feature['enabled_by_default']:
|
|
75
|
+
result[feature_id]={'status':'disabled','check':name}
|
|
76
|
+
elif name is None:
|
|
77
|
+
result[feature_id]={'status':'skip','check':None}
|
|
78
|
+
elif name not in observations:
|
|
79
|
+
result[feature_id]={'status':'fail','check':name,'reason':'check_missing'}
|
|
80
|
+
else:
|
|
81
|
+
value=observations[name]
|
|
82
|
+
if isinstance(value,dict):
|
|
83
|
+
status=('skip' if value.get('status')=='disabled' else
|
|
84
|
+
'pass' if value.get('ok') is True else 'fail')
|
|
85
|
+
else:
|
|
86
|
+
status='pass' if value is True else 'fail'
|
|
87
|
+
result[feature_id]={'status':status,'check':name}
|
|
88
|
+
return result
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def inspect_recovery_checkpoint():
|
|
92
|
+
"""Compare packaged replay schema and code vocabulary; opens no database or quarantine."""
|
|
93
|
+
from importlib.resources import files
|
|
94
|
+
|
|
95
|
+
from msg.admin.recovery_replay import FORMAT, SUPPORTED_FACTS
|
|
96
|
+
schema=loads(files('msg.data').joinpath('recovery-checkpoint.schema.json').read_bytes())
|
|
97
|
+
checkpoint=schema['properties']['checkpoint']['properties']
|
|
98
|
+
kinds=checkpoint['entries']['items']['properties']['kind']['enum']
|
|
99
|
+
require(checkpoint['format'].get('const')==FORMAT and len(kinds)==len(set(kinds)) and
|
|
100
|
+
set(kinds)==SUPPORTED_FACTS,'recovery_checkpoint_schema_drift')
|
|
101
|
+
return {'fact_kinds':len(kinds),'promotion':'blocked'}
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def inspect_lexical_search(app):
|
|
105
|
+
specs=[spec for spec in app.registry.operations() if spec.name=='discovery.lexical_search']
|
|
106
|
+
require(bool(specs) and all(spec.effect=='read' for spec in specs),'lexical_search_contract_invalid')
|
|
107
|
+
return {'versions':sorted(spec.version for spec in specs),'read_only':True}
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def inspect_hosting(app):
|
|
111
|
+
for name in ('hosting.create','hosting.deploy'):
|
|
112
|
+
require(app.registry.operation(name).require_signature,'hosting_contract_invalid')
|
|
113
|
+
app.registry.resource_type('website')
|
|
114
|
+
return {'operations':['hosting.create@1','hosting.deploy@1']}
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
async def _selftest_search_hosting(app,call,register,test_path):
|
|
118
|
+
"""Exercise search and publishing only inside the disposable test namespace."""
|
|
119
|
+
handle='test-features'
|
|
120
|
+
key,uid=await register(handle)
|
|
121
|
+
marker='selftestsearch'+uuid.uuid4().hex[:12]
|
|
122
|
+
search=False
|
|
123
|
+
try:
|
|
124
|
+
post=await call('content.post_create',{'parent':test_path+'/tmp','body':'isolated '+marker},key,uid)
|
|
125
|
+
require(post.status=='ok','selftest_search_post_failed')
|
|
126
|
+
found=await call('discovery.lexical_search',{'scope':test_path+'/tmp','terms':marker},
|
|
127
|
+
key,uid,contract_version=4)
|
|
128
|
+
denied=await call('discovery.lexical_search',{'scope':test_path+'/private','terms':marker},
|
|
129
|
+
key,uid,contract_version=4)
|
|
130
|
+
search=(found.status=='ok' and
|
|
131
|
+
[item['id'] for item in found.data['items']]==[post.resources[0].id] and
|
|
132
|
+
denied.error is not None and denied.error.code=='permission_denied')
|
|
133
|
+
except Failure:
|
|
134
|
+
search=False
|
|
135
|
+
hosting=False
|
|
136
|
+
try:
|
|
137
|
+
page=b'<h1>isolated selftest</h1>'
|
|
138
|
+
source=await call('content.file_put',{'parent':test_path+'/@'+handle+'/files','name':'index.html',
|
|
139
|
+
'data':b64(page),'media_type':'text/html'},key,uid)
|
|
140
|
+
require(source.status=='ok','selftest_hosting_source_failed')
|
|
141
|
+
site=await call('hosting.create',{'parent':test_path+'/@'+handle,'name':'site'},key,uid)
|
|
142
|
+
require(site.status=='ok','selftest_hosting_create_failed')
|
|
143
|
+
website_id=site.resources[0].id
|
|
144
|
+
deployed=await call('hosting.deploy',{'id':website_id,
|
|
145
|
+
'entries':[{'path':'index.html','source':wire(source.resources[0])}]},key,uid,
|
|
146
|
+
expected=((website_id,site.data['generation']),))
|
|
147
|
+
require(deployed.status=='ok','selftest_hosting_deploy_failed')
|
|
148
|
+
async with app.metadata.transaction(write=False) as tx:
|
|
149
|
+
website=await tx.resource(website_id)
|
|
150
|
+
revision=await tx.revision(ResourceRef(id=website_id,revision=website.revision))
|
|
151
|
+
manifest=loads(await app.contents.read_bytes(revision.content))
|
|
152
|
+
entry=decode(ResourceRef,manifest['entries']['index.html'])
|
|
153
|
+
published=await app.contents.read_bytes((await tx.revision(entry)).content)
|
|
154
|
+
site_path=await tx.path(website_id)
|
|
155
|
+
hosting=(site_path.startswith(test_path+'/') and set(manifest['entries'])=={'index.html'} and
|
|
156
|
+
published==page)
|
|
157
|
+
except (Failure,KeyError,TypeError,ValueError):
|
|
158
|
+
hosting=False
|
|
159
|
+
return {'lexical_search_access':search,'hosting_deploy':hosting}
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
async def authority_snapshot_drift(app, root, online, tx):
|
|
163
|
+
"""Show signed authority missing from this installation's current vocabulary.
|
|
164
|
+
|
|
165
|
+
Registry changes are never silently copied into a signed trust anchor or CA.
|
|
166
|
+
Compare individual operations, because a capability can gain an operation
|
|
167
|
+
without changing its name or version. Scope and constraints are checked as
|
|
168
|
+
well; a narrower old grant must not be described as covering a broad one.
|
|
169
|
+
"""
|
|
170
|
+
from msg.security.policy import constraints_subset, scope_subset
|
|
171
|
+
require(root.issuance is not None and online.issuance is not None,'issuer_not_ca')
|
|
172
|
+
|
|
173
|
+
async def missing(expected, actual):
|
|
174
|
+
gaps=[]
|
|
175
|
+
for desired in expected:
|
|
176
|
+
covered=set()
|
|
177
|
+
for grant in actual:
|
|
178
|
+
if (grant.capability==desired.capability and grant.version==desired.version and
|
|
179
|
+
await scope_subset(desired.scope,grant.scope,tx) and
|
|
180
|
+
constraints_subset(desired.constraints,grant.constraints)):
|
|
181
|
+
covered.update(grant.operations)
|
|
182
|
+
operations=sorted(desired.operations-covered)
|
|
183
|
+
if operations:
|
|
184
|
+
gaps.append({'capability':desired.capability,'version':desired.version,
|
|
185
|
+
'scope':wire(desired.scope),'operations':operations})
|
|
186
|
+
return gaps
|
|
187
|
+
|
|
188
|
+
primary=app.primary_ceiling()
|
|
189
|
+
base=app.base_grants()
|
|
190
|
+
return {'root_use':await missing(primary,root.grants),
|
|
191
|
+
'root_issue':await missing(primary,root.issuance.issue_grants),
|
|
192
|
+
'online_issue':await missing(base,online.issuance.issue_grants)}
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def doctor(config_dir=Path('/etc/msgd'), *, clock=None):
|
|
196
|
+
"""This synchronous wrapper is also safe to call from an existing event loop."""
|
|
197
|
+
# Running the read-only coroutine in a short-lived thread avoids nesting an
|
|
198
|
+
# asyncio.run() inside adapters/tests with an already running event loop.
|
|
199
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
200
|
+
with ThreadPoolExecutor(max_workers=1) as pool:
|
|
201
|
+
return pool.submit(lambda: asyncio.run(_doctor(config_dir,clock))).result()
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
async def _doctor(config_dir,clock):
|
|
205
|
+
from msg.application import Application
|
|
206
|
+
from msg.config import load_settings
|
|
207
|
+
from msg.bootstrap import manifest,feature_manifest
|
|
208
|
+
from msg.security.certificates import CertificateValidator
|
|
209
|
+
checks={}
|
|
210
|
+
warnings=[]
|
|
211
|
+
def success(name,**data):checks[name]={'ok':True,**data}
|
|
212
|
+
def failed(name,code,**data):checks[name]={'ok':False,'code':code,**data}
|
|
213
|
+
path=Path(config_dir)
|
|
214
|
+
try:
|
|
215
|
+
settings=load_settings(path)
|
|
216
|
+
app=Application(settings,clock=clock)
|
|
217
|
+
success('configuration')
|
|
218
|
+
success('money_config',enabled=settings.money.enabled,
|
|
219
|
+
currency_id=settings.money.currency_id,
|
|
220
|
+
display_name=settings.money.display_name,code=settings.money.code,
|
|
221
|
+
scale=settings.money.scale,transfer_fee=settings.money.transfer_fee,
|
|
222
|
+
allow_overdraft=settings.money.allow_overdraft)
|
|
223
|
+
except (Failure,ValueError,KeyError,OSError) as exc:
|
|
224
|
+
failed('configuration',getattr(exc,'code','invalid_configuration'))
|
|
225
|
+
return {'ok':False,'root_id':ROOT_SUBJECT,'checks':checks}
|
|
226
|
+
if sys.version_info[:2]>=(3,15):success('python',version='.'.join(map(str,sys.version_info[:3])))
|
|
227
|
+
else:failed('python','python_315_required',actual='.'.join(map(str,sys.version_info[:3])))
|
|
228
|
+
missing=[name for name in ('cryptography','starlette','uvicorn','httpx','jsonschema','aiohttp','dns','graphql','psycopg','valkey') if find_spec(name) is None]
|
|
229
|
+
if not missing:success('dependencies')
|
|
230
|
+
else:failed('dependencies','dependency_unavailable',missing=missing)
|
|
231
|
+
if shutil.which('git'):success('git')
|
|
232
|
+
else:failed('git','git_missing')
|
|
233
|
+
if not shutil.which('bwrap'):
|
|
234
|
+
warnings.append({'code':'tool_isolation_unavailable','effect':'network_tool_jobs_fail_closed'})
|
|
235
|
+
try:
|
|
236
|
+
trust=loads(settings.trust_file.read_bytes())
|
|
237
|
+
root=decode(Certificate,trust['certificate'])
|
|
238
|
+
validator=CertificateValidator(app.registry,root,unb64(trust['public_key']),settings.service_url,
|
|
239
|
+
clock or (lambda:datetime.now(UTC)))
|
|
240
|
+
async def inspect(tx):
|
|
241
|
+
require(tx.one('SELECT version FROM schema_version')[0]==1,'schema_version_unknown')
|
|
242
|
+
success('storage')
|
|
243
|
+
try:
|
|
244
|
+
from msg.admin.custodial_check import inspect_custodial_history
|
|
245
|
+
success('custodial_history', **await inspect_custodial_history(tx))
|
|
246
|
+
except Failure as exc:failed('custodial_history',exc.code)
|
|
247
|
+
if 'orders' in settings.server.plugins:
|
|
248
|
+
try:
|
|
249
|
+
from msg.admin.market_check import inspect_market
|
|
250
|
+
success('market',**(await inspect_market(tx)))
|
|
251
|
+
except (Failure,KeyError,ValueError,psycopg.Error) as exc:
|
|
252
|
+
failed('market',getattr(exc,'code','market_inspection_failed'))
|
|
253
|
+
else:
|
|
254
|
+
checks['market']={'ok':True,'status':'disabled'}
|
|
255
|
+
try:
|
|
256
|
+
from msg.admin.market_check import inspect_clearing
|
|
257
|
+
success('market_clearing',**inspect_clearing(app,tx))
|
|
258
|
+
except (Failure,OSError,ValueError,KeyError,psycopg.Error) as exc:
|
|
259
|
+
failed('market_clearing',getattr(exc,'code','market_inspection_failed'))
|
|
260
|
+
try:
|
|
261
|
+
from msg.core.requests import SECRET_DELIVERY_MIN_VERSION
|
|
262
|
+
columns={row[0] for row in tx.rows("SELECT column_name FROM information_schema.columns "
|
|
263
|
+
"WHERE table_schema='public' AND table_name='token_deliveries'")}
|
|
264
|
+
require({'credential_id','subject','request_id','request_digest','recovery_verifier',
|
|
265
|
+
'recovery_expires_at','claimed_at','consumed_at'} <= columns,
|
|
266
|
+
'credential_delivery_schema_missing')
|
|
267
|
+
for operation,version in SECRET_DELIVERY_MIN_VERSION.items():
|
|
268
|
+
require(app.registry.operation(operation,version).effect=='transaction',
|
|
269
|
+
'credential_delivery_contract_invalid')
|
|
270
|
+
success('credential_delivery',recovery_window_seconds=settings.credential_delivery_recovery_window,
|
|
271
|
+
release='once',secret_url=False,versions=SECRET_DELIVERY_MIN_VERSION)
|
|
272
|
+
except Failure as exc:failed('credential_delivery',exc.code)
|
|
273
|
+
if 'communication' in settings.server.plugins:
|
|
274
|
+
try:
|
|
275
|
+
spec=app.registry.operation('communication.following')
|
|
276
|
+
require(spec.effect=='read','following_contract_invalid')
|
|
277
|
+
columns={row[0] for row in tx.rows("SELECT column_name FROM information_schema.columns "
|
|
278
|
+
"WHERE table_schema='public' AND table_name='watches'")}
|
|
279
|
+
require({'subject','resource'}<=columns,'following_schema_missing')
|
|
280
|
+
success('following',default='empty',read_only=True,max_page_size=100)
|
|
281
|
+
except Failure as exc:failed('following',exc.code)
|
|
282
|
+
else:
|
|
283
|
+
checks['following']={'ok':True,'status':'disabled'}
|
|
284
|
+
try:
|
|
285
|
+
await validator.validate(root.resource_id,tx)
|
|
286
|
+
require((await tx.subject(ROOT_SUBJECT)).local_only,'root_policy_corrupt')
|
|
287
|
+
success('root_trust',fingerprint=digest(unb64(trust['public_key'])))
|
|
288
|
+
except Failure as exc:failed('root_trust',exc.code)
|
|
289
|
+
try:
|
|
290
|
+
online=tx.setting('online_ca_certificate')
|
|
291
|
+
require(online is not None,'issuer_not_ready')
|
|
292
|
+
online_certificate=await validator.validate(online,tx)
|
|
293
|
+
from msg.security.crypto import Ed25519Signer
|
|
294
|
+
online_key=Ed25519Signer.from_bytes((settings.service_keys/'online.key').read_bytes())
|
|
295
|
+
require(online_certificate.key_id==online_key.key_id,'issuer_key_mismatch')
|
|
296
|
+
success('online_ca')
|
|
297
|
+
except (Failure,OSError,ValueError) as exc:failed('online_ca',getattr(exc,'code','issuer_key_missing'))
|
|
298
|
+
try:
|
|
299
|
+
online_id=tx.setting('online_ca_certificate')
|
|
300
|
+
require(online_id is not None,'issuer_not_ready')
|
|
301
|
+
online_certificate=await tx.certificate(online_id)
|
|
302
|
+
gaps=await authority_snapshot_drift(app,root,online_certificate,tx)
|
|
303
|
+
if any(gaps.values()):
|
|
304
|
+
failed('authority_snapshot','signed_authority_outdated',missing=gaps,
|
|
305
|
+
action='No automatic authority expansion. Preserve this chain and keep the new '
|
|
306
|
+
'operation unavailable, or explicitly rotate the root and reissue '
|
|
307
|
+
'certificates; rotation invalidates old chains.')
|
|
308
|
+
else:
|
|
309
|
+
success('authority_snapshot')
|
|
310
|
+
except Failure as exc:
|
|
311
|
+
failed('authority_snapshot',exc.code)
|
|
312
|
+
drift=[]
|
|
313
|
+
for expected in manifest()['resources']:
|
|
314
|
+
try:
|
|
315
|
+
resource=await tx.resource(expected['id'])
|
|
316
|
+
for field in ('type','parent','owner','group','mode','name'):
|
|
317
|
+
desired=int(expected[field],8) if field=='mode' else expected[field]
|
|
318
|
+
if getattr(resource,field)!=desired:drift.append({'id':resource.id,'field':field})
|
|
319
|
+
except Failure:drift.append({'id':expected['id'],'field':'missing'})
|
|
320
|
+
if drift:failed('bootstrap','bootstrap_drift',drift=drift)
|
|
321
|
+
else:success('bootstrap')
|
|
322
|
+
previous=None
|
|
323
|
+
for raw, in tx.execute('SELECT body FROM audit ORDER BY seq'):
|
|
324
|
+
audit=loads(raw)
|
|
325
|
+
entry_digest=audit.pop('entry_digest')
|
|
326
|
+
require(audit.get('previous_digest')==previous and digest(audit)==entry_digest,'audit_chain_mismatch')
|
|
327
|
+
previous=entry_digest
|
|
328
|
+
success('audit',head=previous)
|
|
329
|
+
await ReadOnlyStore(settings.server.postgres_dsn).inspect(inspect)
|
|
330
|
+
except (Failure,OSError,ValueError,KeyError,psycopg.Error) as exc:
|
|
331
|
+
failed('inspection',getattr(exc,'code','inspection_failed'))
|
|
332
|
+
if settings.server.valkey_url:
|
|
333
|
+
try:
|
|
334
|
+
from msg.storage.valkey_bus import ValkeyOutboxSignal
|
|
335
|
+
await asyncio.to_thread(ValkeyOutboxSignal(settings.server.valkey_url).client.ping)
|
|
336
|
+
success('valkey')
|
|
337
|
+
except Exception:
|
|
338
|
+
failed('valkey','valkey_unavailable')
|
|
339
|
+
else:
|
|
340
|
+
checks['valkey']={'ok':True,'status':'disabled'}
|
|
341
|
+
content=settings.server.content_dir
|
|
342
|
+
if (content/'private.git'/'HEAD').is_file():success('content_layout')
|
|
343
|
+
else:failed('content_layout','content_missing')
|
|
344
|
+
if settings.server.staging_dir.exists():
|
|
345
|
+
disk=shutil.disk_usage(settings.server.staging_dir)
|
|
346
|
+
success('capacity',free_bytes=disk.free)
|
|
347
|
+
from msg.admin.root import root_envelope
|
|
348
|
+
protected=root_envelope(path).parent
|
|
349
|
+
if protected==path/'root':
|
|
350
|
+
warnings.append({'code':'legacy_root_storage','effect':'migrate_to_var_lib_msgd_root'})
|
|
351
|
+
try:
|
|
352
|
+
require(not ((path/'root'/'key.json').exists() and
|
|
353
|
+
(settings.root_private_dir/'key.json').exists()),
|
|
354
|
+
'duplicate_root_material')
|
|
355
|
+
mode=stat.S_IMODE(protected.stat().st_mode)
|
|
356
|
+
require(mode==0o700 and protected.stat().st_uid==0,'unsafe_root_permissions')
|
|
357
|
+
secret=protected/'key.json'
|
|
358
|
+
try:
|
|
359
|
+
require(stat.S_IMODE(secret.stat().st_mode)==0o600 and secret.stat().st_uid==0,'unsafe_root_permissions')
|
|
360
|
+
except PermissionError:
|
|
361
|
+
# The network service being unable to inspect a root-owned 0700
|
|
362
|
+
# directory is the intended deployment boundary.
|
|
363
|
+
pass
|
|
364
|
+
success('root_private_boundary')
|
|
365
|
+
except (Failure,OSError) as exc:failed('root_private_boundary',getattr(exc,'code','root_material_missing'))
|
|
366
|
+
try:success('recovery_checkpoint',**inspect_recovery_checkpoint())
|
|
367
|
+
except (Failure,OSError,ValueError,KeyError,TypeError,AttributeError) as exc:
|
|
368
|
+
failed('recovery_checkpoint',getattr(exc,'code','recovery_checkpoint_schema_invalid'))
|
|
369
|
+
for name,plugin,inspect_feature in (('lexical_search','discovery',inspect_lexical_search),
|
|
370
|
+
('hosting','extensions',inspect_hosting)):
|
|
371
|
+
if plugin not in settings.server.plugins:
|
|
372
|
+
checks[name]={'ok':True,'status':'disabled'}
|
|
373
|
+
continue
|
|
374
|
+
try:success(name,**inspect_feature(app))
|
|
375
|
+
except Failure as exc:failed(name,exc.code)
|
|
376
|
+
features=feature_results(feature_manifest(),checks,'doctor_check')
|
|
377
|
+
return {'ok':all(c['ok'] for c in checks.values()) and
|
|
378
|
+
all(row['status']!='fail' for row in features.values()),
|
|
379
|
+
'root_id':ROOT_SUBJECT,'checks':checks,'features':features,'warnings':warnings}
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
async def _selftest_ca_chain(app,root,call,register,now):
|
|
383
|
+
"""Publish a real, narrowly scoped CA chain in the disposable test instance."""
|
|
384
|
+
from dataclasses import replace
|
|
385
|
+
from msg.core.models import CertificateRequest, IssuancePolicy, Scope, Signature
|
|
386
|
+
from msg.plugins.common import new_id
|
|
387
|
+
from msg.security.capabilities import grant_for
|
|
388
|
+
from msg.security.certificates import csr_body, sign_certificate
|
|
389
|
+
from msg.admin.root import _approve_csr
|
|
390
|
+
|
|
391
|
+
scope=Scope(resource_id=app.namespace_root,descendants=True)
|
|
392
|
+
issue=grant_for(app.registry.capability('cert.issue'),scope=scope,
|
|
393
|
+
operations=('cert.publish@1',))
|
|
394
|
+
ca_issue=grant_for(app.registry.capability('cert.ca.issue'),scope=scope,
|
|
395
|
+
operations=('cert.publish@1',))
|
|
396
|
+
read=grant_for(app.registry.capability('discovery.basic'),scope=scope,
|
|
397
|
+
operations=('discovery.get@1',))
|
|
398
|
+
bounded_tool=replace(grant_for(app.registry.capability('tool.use'),
|
|
399
|
+
scope=Scope(resource_id='t_tools'),operations=('tool.run@1',)),
|
|
400
|
+
constraints={'hosts':['example.org']})
|
|
401
|
+
ca_grants=(issue,ca_issue)
|
|
402
|
+
issuable=(issue,ca_issue,read,bounded_tool)
|
|
403
|
+
|
|
404
|
+
async def request_certificate(key,uid,issuer,kind,grants,policy=None,ttl=600):
|
|
405
|
+
csr=CertificateRequest(resource_id='pending',applicant=uid,subject_id=uid,
|
|
406
|
+
requested_issuer=issuer,public_key=key.public_key,kind=kind,grants=tuple(grants),
|
|
407
|
+
issuance=policy,requested_ttl_seconds=ttl,target_service=app.settings.service_url,
|
|
408
|
+
delegation_depth=0,authority_sources=(),request_digest='',
|
|
409
|
+
possession_proof=Signature(key_id=key.key_id,algorithm='ed25519',value=b''))
|
|
410
|
+
proof=key.sign(canonical(csr_body(csr)),purpose='csr')
|
|
411
|
+
result=await call('cert.request',{'requested_issuer':issuer,'public_key':b64(key.public_key),
|
|
412
|
+
'kind':kind,'grants':wire(tuple(grants)),'issuance':wire(policy),
|
|
413
|
+
'requested_ttl_seconds':ttl,'possession_proof':wire(proof)},key,uid)
|
|
414
|
+
require(result.status=='ok',result.error.code if result.error else 'selftest_csr_failed')
|
|
415
|
+
return result
|
|
416
|
+
|
|
417
|
+
async def publish(parent,parent_key,parent_uid,key,uid,kind,grants,policy=None,
|
|
418
|
+
*,ttl=600,target_service=None):
|
|
419
|
+
requested=await request_certificate(key,uid,parent_uid,kind,grants,policy,ttl)
|
|
420
|
+
cert=Certificate(resource_id=new_id('cert'),serial=new_id('serial'),subject_id=uid,
|
|
421
|
+
key_id=key.key_id,issuer_id=parent_uid,parent_certificate_id=parent.resource_id,
|
|
422
|
+
authority_sources=(),kind=kind,grants=tuple(grants),not_before=now,
|
|
423
|
+
expires_at=now+timedelta(seconds=ttl),
|
|
424
|
+
target_service=target_service or app.settings.service_url,
|
|
425
|
+
delegation_depth=0,issuance=policy,
|
|
426
|
+
signature=Signature(key_id=parent_key.key_id,algorithm='ed25519',value=b''))
|
|
427
|
+
cert=sign_certificate(cert,parent_key)
|
|
428
|
+
result=await call('cert.publish',{'csr_id':requested.data['csr_id'],
|
|
429
|
+
'certificate':wire(cert)},parent_key,parent_uid,(parent.resource_id,))
|
|
430
|
+
return result,cert,requested
|
|
431
|
+
|
|
432
|
+
l1_key,l1_uid=await register('test-l1')
|
|
433
|
+
l1_policy=IssuancePolicy(issue_grants=issuable,max_cert_ttl_seconds=600,
|
|
434
|
+
max_child_ca_depth=2,max_delegation_depth=0)
|
|
435
|
+
l1_request=await request_certificate(l1_key,l1_uid,ROOT_SUBJECT,'ca',ca_grants,l1_policy)
|
|
436
|
+
l1=await _approve_csr(app,l1_request.data['csr_id'],root,
|
|
437
|
+
expected_digest=l1_request.data['request_digest'],operator='isolated-selftest')
|
|
438
|
+
l2_key,l2_uid=await register('test-l2')
|
|
439
|
+
l2_policy=IssuancePolicy(issue_grants=issuable,max_cert_ttl_seconds=600,
|
|
440
|
+
max_child_ca_depth=1,max_delegation_depth=0)
|
|
441
|
+
l2_result,l2,_=await publish(l1,l1_key,l1_uid,l2_key,l2_uid,'ca',ca_grants,l2_policy)
|
|
442
|
+
require(l2_result.status=='ok',l2_result.error.code if l2_result.error else 'selftest_l2_failed')
|
|
443
|
+
l3_key,l3_uid=await register('test-l3')
|
|
444
|
+
l3_policy=IssuancePolicy(issue_grants=issuable,max_cert_ttl_seconds=600,
|
|
445
|
+
max_child_ca_depth=0,max_delegation_depth=0)
|
|
446
|
+
l3_result,l3,_=await publish(l2,l2_key,l2_uid,l3_key,l3_uid,'ca',ca_grants,l3_policy)
|
|
447
|
+
require(l3_result.status=='ok',l3_result.error.code if l3_result.error else 'selftest_l3_failed')
|
|
448
|
+
leaf_key,leaf_uid=await register('test-leaf')
|
|
449
|
+
leaf_result,leaf,_=await publish(l3,l3_key,l3_uid,leaf_key,leaf_uid,'capability',(read,))
|
|
450
|
+
require(leaf_result.status=='ok',leaf_result.error.code if leaf_result.error else 'selftest_leaf_failed')
|
|
451
|
+
async with app.metadata.transaction(write=False) as tx:
|
|
452
|
+
for cert in (l1,l2,l3,leaf):
|
|
453
|
+
await app.certificates.validate(cert.resource_id,tx)
|
|
454
|
+
paths=[await tx.path(cert.resource_id) for cert in (l1,l2,l3,leaf)]
|
|
455
|
+
scope_ok=True
|
|
456
|
+
for cert in (l1,l2,l3,leaf):
|
|
457
|
+
for grant in (*cert.grants,*(cert.issuance.issue_grants if cert.issuance else ())):
|
|
458
|
+
if grant.scope.resource_id==app.namespace_root:
|
|
459
|
+
continue
|
|
460
|
+
if app.namespace_root not in {item.id for item in await tx.ancestors(grant.scope.resource_id)}:
|
|
461
|
+
scope_ok=False
|
|
462
|
+
test_path='/_test/'+app.selftest_run_id
|
|
463
|
+
valid=all(path.startswith(test_path+'/') for path in paths) and scope_ok
|
|
464
|
+
|
|
465
|
+
l4_key,l4_uid=await register('test-l4')
|
|
466
|
+
l4_result,l4,l4_request=await publish(l3,l3_key,l3_uid,l4_key,l4_uid,'ca',ca_grants,l3_policy)
|
|
467
|
+
async with app.metadata.transaction(write=False) as tx:
|
|
468
|
+
pending=(await tx.csr_state(l4_request.data['csr_id'])).status=='pending'
|
|
469
|
+
absent=tx.one('SELECT 1 FROM certificates WHERE id=?',(l4.resource_id,)) is None
|
|
470
|
+
denied=(l4_result.error is not None and l4_result.error.code=='ca_depth_exceeded'
|
|
471
|
+
and pending and absent)
|
|
472
|
+
|
|
473
|
+
out_of_scope=grant_for(app.registry.capability('discovery.basic'),
|
|
474
|
+
scope=Scope(resource_id='r_root',descendants=True),operations=('discovery.get@1',))
|
|
475
|
+
bad_scope_key,bad_scope_uid=await register('test-bad-scope')
|
|
476
|
+
bad_scope,bad_scope_cert,bad_scope_request=await publish(
|
|
477
|
+
l1,l1_key,l1_uid,bad_scope_key,bad_scope_uid,'capability',(out_of_scope,))
|
|
478
|
+
async with app.metadata.transaction(write=False) as tx:
|
|
479
|
+
scope_pending=(await tx.csr_state(bad_scope_request.data['csr_id'])).status=='pending'
|
|
480
|
+
scope_absent=tx.one('SELECT 1 FROM certificates WHERE id=?',(bad_scope_cert.resource_id,)) is None
|
|
481
|
+
denied_scope=(bad_scope.error is not None and bad_scope.error.code=='issuance_scope_exceeded'
|
|
482
|
+
and scope_pending and scope_absent)
|
|
483
|
+
|
|
484
|
+
broad_read=grant_for(app.registry.capability('discovery.basic'),scope=scope,
|
|
485
|
+
operations=('discovery.get@1','discovery.list@1'))
|
|
486
|
+
bad_ops_key,bad_ops_uid=await register('test-bad-ops')
|
|
487
|
+
bad_ops,bad_ops_cert,bad_ops_request=await publish(
|
|
488
|
+
l1,l1_key,l1_uid,bad_ops_key,bad_ops_uid,'capability',(broad_read,))
|
|
489
|
+
async with app.metadata.transaction(write=False) as tx:
|
|
490
|
+
ops_pending=(await tx.csr_state(bad_ops_request.data['csr_id'])).status=='pending'
|
|
491
|
+
ops_absent=tx.one('SELECT 1 FROM certificates WHERE id=?',(bad_ops_cert.resource_id,)) is None
|
|
492
|
+
denied_ops=(bad_ops.error is not None and bad_ops.error.code=='issuance_scope_exceeded'
|
|
493
|
+
and ops_pending and ops_absent)
|
|
494
|
+
|
|
495
|
+
broad_tool=replace(bounded_tool,constraints={'hosts':['example.org','other.example']})
|
|
496
|
+
bad_constraints_key,bad_constraints_uid=await register('test-bad-constraints')
|
|
497
|
+
bad_constraints,bad_constraints_cert,bad_constraints_request=await publish(
|
|
498
|
+
l1,l1_key,l1_uid,bad_constraints_key,bad_constraints_uid,'capability',(broad_tool,))
|
|
499
|
+
async with app.metadata.transaction(write=False) as tx:
|
|
500
|
+
constraints_pending=(await tx.csr_state(bad_constraints_request.data['csr_id'])).status=='pending'
|
|
501
|
+
constraints_absent=tx.one('SELECT 1 FROM certificates WHERE id=?',
|
|
502
|
+
(bad_constraints_cert.resource_id,)) is None
|
|
503
|
+
denied_constraints=(bad_constraints.error is not None and
|
|
504
|
+
bad_constraints.error.code=='issuance_scope_exceeded' and
|
|
505
|
+
constraints_pending and constraints_absent)
|
|
506
|
+
|
|
507
|
+
bad_ttl_key,bad_ttl_uid=await register('test-bad-ttl')
|
|
508
|
+
bad_ttl,bad_ttl_cert,bad_ttl_request=await publish(
|
|
509
|
+
l1,l1_key,l1_uid,bad_ttl_key,bad_ttl_uid,'capability',(read,),ttl=601)
|
|
510
|
+
async with app.metadata.transaction(write=False) as tx:
|
|
511
|
+
ttl_pending=(await tx.csr_state(bad_ttl_request.data['csr_id'])).status=='pending'
|
|
512
|
+
ttl_absent=tx.one('SELECT 1 FROM certificates WHERE id=?',(bad_ttl_cert.resource_id,)) is None
|
|
513
|
+
denied_ttl=(bad_ttl.error is not None and bad_ttl.error.code in
|
|
514
|
+
{'certificate_validity_escalation','certificate_ttl_escalation'} and
|
|
515
|
+
ttl_pending and ttl_absent)
|
|
516
|
+
|
|
517
|
+
bad_service_key,bad_service_uid=await register('test-bad-service')
|
|
518
|
+
bad_service,bad_service_cert,bad_service_request=await publish(
|
|
519
|
+
l1,l1_key,l1_uid,bad_service_key,bad_service_uid,'capability',(read,),
|
|
520
|
+
target_service='https://wrong-service.invalid')
|
|
521
|
+
async with app.metadata.transaction(write=False) as tx:
|
|
522
|
+
service_pending=(await tx.csr_state(bad_service_request.data['csr_id'])).status=='pending'
|
|
523
|
+
service_absent=tx.one('SELECT 1 FROM certificates WHERE id=?',(bad_service_cert.resource_id,)) is None
|
|
524
|
+
denied_service=(bad_service.error is not None and bad_service.error.code=='csr_certificate_mismatch'
|
|
525
|
+
and service_pending and service_absent)
|
|
526
|
+
|
|
527
|
+
bad_policy=IssuancePolicy(issue_grants=(*issuable,broad_read),max_cert_ttl_seconds=600,
|
|
528
|
+
max_child_ca_depth=1,max_delegation_depth=0)
|
|
529
|
+
bad_policy_key,bad_policy_uid=await register('test-bad-issue-grants')
|
|
530
|
+
bad_issue_grants,bad_policy_cert,bad_policy_request=await publish(
|
|
531
|
+
l1,l1_key,l1_uid,bad_policy_key,bad_policy_uid,'ca',ca_grants,bad_policy)
|
|
532
|
+
async with app.metadata.transaction(write=False) as tx:
|
|
533
|
+
policy_pending=(await tx.csr_state(bad_policy_request.data['csr_id'])).status=='pending'
|
|
534
|
+
policy_absent=tx.one('SELECT 1 FROM certificates WHERE id=?',(bad_policy_cert.resource_id,)) is None
|
|
535
|
+
denied_policy=(bad_issue_grants.error is not None and
|
|
536
|
+
bad_issue_grants.error.code=='issuance_scope_exceeded' and
|
|
537
|
+
policy_pending and policy_absent)
|
|
538
|
+
|
|
539
|
+
child_key,child_uid=await register('test-leaf-child')
|
|
540
|
+
leaf_issue,leaf_child,leaf_request=await publish(
|
|
541
|
+
leaf,leaf_key,leaf_uid,child_key,child_uid,'capability',(read,))
|
|
542
|
+
async with app.metadata.transaction(write=False) as tx:
|
|
543
|
+
leaf_pending=(await tx.csr_state(leaf_request.data['csr_id'])).status=='pending'
|
|
544
|
+
leaf_absent=tx.one('SELECT 1 FROM certificates WHERE id=?',(leaf_child.resource_id,)) is None
|
|
545
|
+
leaf_denied=(leaf_issue.error is not None and leaf_pending and leaf_absent)
|
|
546
|
+
|
|
547
|
+
revoked=True
|
|
548
|
+
for ancestor in (l1,l2,l3):
|
|
549
|
+
async with app.metadata.transaction(write=True) as tx:
|
|
550
|
+
tx.execute('UPDATE certificates SET revoked=1 WHERE id=?',(ancestor.resource_id,),write=True)
|
|
551
|
+
async with app.metadata.transaction(write=False) as tx:
|
|
552
|
+
try:
|
|
553
|
+
await app.certificates.validate(leaf.resource_id,tx)
|
|
554
|
+
except Failure as exc:
|
|
555
|
+
revoked=revoked and exc.code=='certificate_revoked'
|
|
556
|
+
else:
|
|
557
|
+
revoked=False
|
|
558
|
+
async with app.metadata.transaction(write=True) as tx:
|
|
559
|
+
tx.execute('UPDATE certificates SET revoked=0 WHERE id=?',(ancestor.resource_id,),write=True)
|
|
560
|
+
return {'ca_l1_l2_l3_leaf':valid,'ca_l3_cannot_issue_ca':denied,
|
|
561
|
+
'ca_ancestor_revocation':revoked,'ca_scope_expansion_denied':denied_scope,
|
|
562
|
+
'ca_operations_expansion_denied':denied_ops,
|
|
563
|
+
'ca_constraints_expansion_denied':denied_constraints,
|
|
564
|
+
'ca_ttl_expansion_denied':denied_ttl,
|
|
565
|
+
'ca_target_service_change_denied':denied_service,
|
|
566
|
+
'ca_issue_grants_expansion_denied':denied_policy,
|
|
567
|
+
'ca_leaf_cannot_issue':leaf_denied}
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
async def selftest():
|
|
571
|
+
"""No production database, listener, root PIN or live trust state is touched."""
|
|
572
|
+
from dataclasses import replace
|
|
573
|
+
from msg.application import Application
|
|
574
|
+
from msg.config import write_example
|
|
575
|
+
from msg.admin.root import _provision,_approve_csr
|
|
576
|
+
from msg.security.crypto import Ed25519Signer,subject_id
|
|
577
|
+
from msg.security.age_keys import generate_age_key
|
|
578
|
+
from msg.core.requests import request_for
|
|
579
|
+
from msg.core.models import CertificateRequest,Signature,Scope
|
|
580
|
+
from msg.security.certificates import csr_body
|
|
581
|
+
from msg.security.capabilities import grant_for
|
|
582
|
+
from msg.bootstrap import feature_manifest
|
|
583
|
+
from msg.admin import recovery_replay
|
|
584
|
+
checks={}
|
|
585
|
+
try:
|
|
586
|
+
inspect_recovery_checkpoint()
|
|
587
|
+
checks['recovery_checkpoint_replay']=recovery_replay.selftest()['status']=='passed'
|
|
588
|
+
except (Failure,OSError,ValueError,KeyError,TypeError,AttributeError):
|
|
589
|
+
checks['recovery_checkpoint_replay']=False
|
|
590
|
+
with tempfile.TemporaryDirectory(prefix='msg-selftest-') as temporary, temporary_postgres() as dsn:
|
|
591
|
+
folder=Path(temporary)
|
|
592
|
+
now=datetime.now(UTC)
|
|
593
|
+
run_id=uuid.uuid4().hex
|
|
594
|
+
test_path='/_test/'+run_id
|
|
595
|
+
app=Application(write_example(folder/'etc',folder/'data','https://selftest.invalid',
|
|
596
|
+
postgres_dsn=dsn),clock=lambda:now,selftest_run_id=run_id)
|
|
597
|
+
try:
|
|
598
|
+
csr,root=await _provision(app,'selftest-'+os.urandom(24).hex())
|
|
599
|
+
await _approve_csr(app,csr,root,expected_digest=None,operator='isolated-selftest')
|
|
600
|
+
async with app.metadata.transaction(write=False) as tx:
|
|
601
|
+
namespace=app.namespace_root
|
|
602
|
+
require(await tx.path(namespace)==test_path,'selftest_namespace_missing')
|
|
603
|
+
root_scoped=True
|
|
604
|
+
for (resource_id,) in tx.execute('SELECT id FROM resources'):
|
|
605
|
+
if resource_id in {'r_root','t_selftest'}:
|
|
606
|
+
continue
|
|
607
|
+
ancestors=await tx.ancestors(resource_id)
|
|
608
|
+
if resource_id!=namespace and namespace not in {item.id for item in ancestors}:
|
|
609
|
+
root_scoped=False
|
|
610
|
+
break
|
|
611
|
+
checks['isolated_namespace']=root_scoped
|
|
612
|
+
async def call(name,args,key=None,subject=None,certs=(),expected=(),request_id=None,
|
|
613
|
+
contract_version=1):
|
|
614
|
+
packet=request_for(name,args,app.settings.service_url,subject=subject,signer=key,
|
|
615
|
+
certificates=certs,expected=expected,expires_at=now+timedelta(seconds=120),
|
|
616
|
+
request_id=request_id,contract_version=contract_version)
|
|
617
|
+
return await app.executor.execute(packet)
|
|
618
|
+
async def register(name):
|
|
619
|
+
key=Ed25519Signer.generate();uid=subject_id(key.public_key)
|
|
620
|
+
_,recipient=generate_age_key()
|
|
621
|
+
result=await call('identity.register',{'handle':name,'public_key':b64(key.public_key),
|
|
622
|
+
'encryption_recipient':recipient},key,uid,contract_version=2)
|
|
623
|
+
require(result.status=='ok','selftest_registration_failed')
|
|
624
|
+
return key,uid
|
|
625
|
+
async def approve(key,uid,grants):
|
|
626
|
+
csr=CertificateRequest(resource_id='pending',applicant=uid,subject_id=uid,requested_issuer=ROOT_SUBJECT,
|
|
627
|
+
public_key=key.public_key,kind='capability',grants=tuple(grants),issuance=None,
|
|
628
|
+
requested_ttl_seconds=600,target_service=app.settings.service_url,delegation_depth=0,authority_sources=(),
|
|
629
|
+
request_digest='',possession_proof=Signature(key_id=key.key_id,algorithm='ed25519',value=b''))
|
|
630
|
+
proof=key.sign(canonical(csr_body(csr)),purpose='csr')
|
|
631
|
+
result=await call('cert.request',{'requested_issuer':ROOT_SUBJECT,'public_key':b64(key.public_key),
|
|
632
|
+
'kind':'capability','grants':wire(grants),'requested_ttl_seconds':600,'possession_proof':wire(proof)},key,uid)
|
|
633
|
+
require(result.status=='ok','selftest_csr_failed')
|
|
634
|
+
return await _approve_csr(app,result.data['csr_id'],root,expected_digest=result.data['request_digest'],operator='isolated-selftest')
|
|
635
|
+
alice,ua=await register('alice');bob,ub=await register('bob')
|
|
636
|
+
post=await call('content.post_create',{'parent':test_path+'/tmp','body':'retained source bytes\r\n'},alice,ua,request_id='same-write')
|
|
637
|
+
require(post.status=='ok',post.error.code if post.error else 'selftest_post_failed')
|
|
638
|
+
# Following reads only existing watches; use a separate fixture so
|
|
639
|
+
# its explicit permission change cannot alter other lifecycle tests.
|
|
640
|
+
empty_following=await call('communication.following',{},bob,ub)
|
|
641
|
+
anonymous_following=await call('communication.following',{})
|
|
642
|
+
followed=await call('content.post_create',
|
|
643
|
+
{'parent':test_path+'/tmp','body':'isolated Following fixture'},alice,ua)
|
|
644
|
+
require(followed.status=='ok','selftest_following_post_failed')
|
|
645
|
+
followed_id=followed.resources[0].id
|
|
646
|
+
watched=await call('communication.watch',{'id':followed_id},bob,ub)
|
|
647
|
+
shown=await call('communication.following',{},bob,ub)
|
|
648
|
+
hidden=await call('content.chmod',{'id':followed_id,'mode':'0600'},alice,ua,
|
|
649
|
+
expected=((followed_id,followed.data['generation']),))
|
|
650
|
+
redacted=await call('communication.following',{},bob,ub)
|
|
651
|
+
checks['following']=(empty_following.status=='ok' and not empty_following.data['items'] and
|
|
652
|
+
anonymous_following.error is not None and
|
|
653
|
+
anonymous_following.error.code=='authentication_required' and
|
|
654
|
+
watched.status=='ok' and shown.status=='ok' and
|
|
655
|
+
[item['id'] for item in shown.data['items']]==[followed_id] and
|
|
656
|
+
hidden.status=='ok' and redacted.status=='ok' and not redacted.data['items'])
|
|
657
|
+
# OnlineIssuer exercises only the independent temporary Test Root
|
|
658
|
+
# created above. No production trust material or signer is opened.
|
|
659
|
+
async with app.metadata.transaction(write=False) as tx:
|
|
660
|
+
identity_cert=next(decode(Certificate,loads(raw)) for (raw,) in
|
|
661
|
+
tx.execute('SELECT body FROM certificates WHERE subject=?',(ua,))
|
|
662
|
+
if decode(Certificate,loads(raw)).kind=='identity')
|
|
663
|
+
issued_audits=[loads(raw) for (raw,) in tx.execute('SELECT body FROM audit ORDER BY seq')
|
|
664
|
+
if loads(raw)['event']['type']=='cert.auto.issue']
|
|
665
|
+
registration_audit=next((entry for entry in issued_audits if
|
|
666
|
+
entry['event']['data']['certificate_id']==identity_cert.resource_id),None)
|
|
667
|
+
checks['online_registration']=bool(registration_audit and
|
|
668
|
+
registration_audit['event']['data']['automatic'] is True and
|
|
669
|
+
registration_audit['event']['data']['authority_source']['key_id']==alice.key_id)
|
|
670
|
+
ordinary=grant_for(app.registry.capability('discovery.basic'),
|
|
671
|
+
scope=Scope(resource_id=post.resources[0].id),operations=('discovery.get@1',))
|
|
672
|
+
delegated=await call('identity.delegate',{'grantee':ub,'key_id':bob.key_id,
|
|
673
|
+
'grants':wire((ordinary,)),'ttl':600},alice,ua,request_id='online-selftest-delegation')
|
|
674
|
+
require(delegated.status=='ok','selftest_online_delegation_failed')
|
|
675
|
+
async with app.metadata.transaction(write=False) as tx:
|
|
676
|
+
audit_rows=[loads(raw) for (raw,) in tx.execute('SELECT body FROM audit ORDER BY seq')]
|
|
677
|
+
delegation_audit=next((entry for entry in audit_rows if
|
|
678
|
+
entry['event']['type']=='cert.auto.issue' and delegated.status=='ok' and
|
|
679
|
+
entry['event']['data']['certificate_id']==delegated.data['certificate_id']),None)
|
|
680
|
+
checks['online_delegation']=bool(delegation_audit and
|
|
681
|
+
delegation_audit['event']['data']['authority_source']['id']==delegated.resources[0].id and
|
|
682
|
+
delegation_audit['event']['data']['grant_digest']==digest((ordinary,)))
|
|
683
|
+
renewed=await call('identity.certificate_renew',{},alice,ua)
|
|
684
|
+
overlong=await call('identity.certificate_renew',{'ttl':31536000},alice,ua)
|
|
685
|
+
checks['online_renewal']=(renewed.status=='ok' and overlong.error is not None and
|
|
686
|
+
overlong.error.code=='renewal_ttl_exceeded')
|
|
687
|
+
revoked_source=await call('identity.delegation_revoke',{'id':delegated.resources[0].id},alice,ua)
|
|
688
|
+
async with app.metadata.transaction(write=False) as tx:
|
|
689
|
+
derived=await app.certificates.validate(identity_cert.resource_id,tx)
|
|
690
|
+
delegation_invalid=False
|
|
691
|
+
try:await app.certificates.validate(delegated.data['certificate_id'],tx)
|
|
692
|
+
except Failure as exc:delegation_invalid=exc.code=='authority_source_inactive'
|
|
693
|
+
checks['online_source_revocation']=revoked_source.status=='ok' and derived.kind=='identity' and delegation_invalid
|
|
694
|
+
repeat=await call('content.post_create',{'parent':test_path+'/tmp','body':'retained source bytes\r\n'},alice,ua,request_id='same-write')
|
|
695
|
+
checks['idempotency']=repeat.replayed and repeat.resources==post.resources
|
|
696
|
+
rejected=await call('content.archive',{'id':post.resources[0].id},bob,ub,
|
|
697
|
+
expected=((post.resources[0].id,post.data['generation']),))
|
|
698
|
+
checks['sticky']=rejected.error is not None and rejected.error.code=='sticky_denied'
|
|
699
|
+
denied=await call('discovery.get',{'id':test_path+'/private'},bob,ub)
|
|
700
|
+
checks['deny']=denied.error is not None and denied.error.code=='permission_denied'
|
|
701
|
+
denied=await call('content.post_create',{'parent':test_path+'/certified','body':'gate'},bob,ub)
|
|
702
|
+
grant=grant_for(app.registry.capability('resource.certified_write'),scope=Scope(resource_id='t_certified',descendants=True),
|
|
703
|
+
operations=('content.post_create@1',))
|
|
704
|
+
cert=await approve(bob,ub,(grant,))
|
|
705
|
+
accepted=await call('content.post_create',{'parent':test_path+'/certified','body':'gate'},bob,ub,(cert.resource_id,))
|
|
706
|
+
checks['certgate']=denied.error is not None and denied.error.code=='certificate_gate' and accepted.status=='ok'
|
|
707
|
+
wrong=grant_for(app.registry.capability('resource.certified_write'),scope=Scope(resource_id='t_main',descendants=True),
|
|
708
|
+
operations=('content.post_create@1',))
|
|
709
|
+
wrong_cert=await approve(alice,ua,(wrong,))
|
|
710
|
+
rejected=await call('content.post_create',{'parent':test_path+'/certified','body':'wrong scope'},alice,ua,(wrong_cert.resource_id,))
|
|
711
|
+
checks['certificate_scope']=rejected.error is not None and rejected.error.code=='certificate_gate'
|
|
712
|
+
manage=grant_for(app.registry.capability('group.manage_override'),scope=Scope(resource_id='g_admins'),
|
|
713
|
+
operations=('group.member.add@1','group.member.remove@1'))
|
|
714
|
+
manager=await approve(alice,ua,(manage,))
|
|
715
|
+
await call('group.member.add',{'group':'g_admins','subject':ub},alice,ua,(manager.resource_id,))
|
|
716
|
+
child=await call('content.topic_create',{'parent':test_path+'/admins','name':'selftest'},bob,ub)
|
|
717
|
+
require(child.status=='ok','selftest_group_failed')
|
|
718
|
+
async with app.metadata.transaction(write=False) as tx:
|
|
719
|
+
resource=await tx.resource(child.resources[0].id)
|
|
720
|
+
checks['setgid']=resource.group=='g_admins' and bool(resource.mode&0o2000)
|
|
721
|
+
await call('group.member.remove',{'group':'g_admins','subject':ub},alice,ua,(manager.resource_id,))
|
|
722
|
+
rejected=await call('discovery.get',{'id':child.resources[0].id},bob,ub)
|
|
723
|
+
checks['membership_revocation']=rejected.error is not None
|
|
724
|
+
async with app.metadata.transaction(write=True) as tx:
|
|
725
|
+
tx.execute('UPDATE certificates SET revoked=1 WHERE id=?',(cert.resource_id,),write=True)
|
|
726
|
+
rejected=await call('content.post_create',{'parent':test_path+'/certified','body':'revoked'},bob,ub,(cert.resource_id,))
|
|
727
|
+
checks['certificate_revocation']=rejected.error is not None and rejected.error.code=='certificate_revoked'
|
|
728
|
+
rejected=await call('discovery.get',{'id':test_path+'/private'},root,ROOT_SUBJECT)
|
|
729
|
+
checks['root_network_rejected']=rejected.error is not None and rejected.error.code=='local_only'
|
|
730
|
+
all_versions=await call('discovery.get',{'id':post.resources[0].id,'view':'meta'})
|
|
731
|
+
checks['stable_id_read']=all_versions.status=='ok'
|
|
732
|
+
checks.update(await _selftest_ca_chain(app,root,call,register,now))
|
|
733
|
+
from msg.admin.upgrade_check import check_upgrade_recovery
|
|
734
|
+
checks['identity_upgrade_recovery']=await check_upgrade_recovery(app,now)
|
|
735
|
+
from msg.admin.token_delivery_check import check_token_delivery
|
|
736
|
+
checks['credential_delivery_recovery']=await check_token_delivery(app,now)
|
|
737
|
+
from msg.admin.custodial_check import check_custodial_history
|
|
738
|
+
checks['custodial_history_recovery']=await check_custodial_history(app,now)
|
|
739
|
+
from msg.admin.market_check import check_market, check_market_e2e
|
|
740
|
+
checks['market_lifecycle']=await check_market(app,root,call,register,now)
|
|
741
|
+
checks['market_e2e']=await check_market_e2e(app,root,call,register)
|
|
742
|
+
checks.update(await _selftest_search_hosting(app,call,register,test_path))
|
|
743
|
+
except Failure as exc:
|
|
744
|
+
checks['failure']={'code':exc.code}
|
|
745
|
+
finally:
|
|
746
|
+
await app.close()
|
|
747
|
+
features=feature_results(feature_manifest(),checks,'selftest_case')
|
|
748
|
+
return {'ok':bool(checks) and all(value is True for value in checks.values()) and
|
|
749
|
+
all(row['status']!='fail' for row in features.values()),
|
|
750
|
+
'checks':checks,'features':features,'cleaned_up':not folder.exists()}
|