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.
Files changed (176) hide show
  1. msg/__init__.py +2 -0
  2. msg/admin/__init__.py +0 -0
  3. msg/admin/backup_retirement.py +56 -0
  4. msg/admin/backups.py +341 -0
  5. msg/admin/custodial_check.py +77 -0
  6. msg/admin/diagnostics.py +750 -0
  7. msg/admin/market.py +91 -0
  8. msg/admin/market_check.py +341 -0
  9. msg/admin/money.py +322 -0
  10. msg/admin/preflight.py +142 -0
  11. msg/admin/recovery_replay.py +281 -0
  12. msg/admin/restore_database.py +37 -0
  13. msg/admin/root.py +347 -0
  14. msg/admin/rotation.py +197 -0
  15. msg/admin/token_delivery_check.py +54 -0
  16. msg/admin/upgrade_check.py +58 -0
  17. msg/application.py +163 -0
  18. msg/bootstrap.py +305 -0
  19. msg/cli.py +537 -0
  20. msg/client.py +725 -0
  21. msg/client_certificates.py +98 -0
  22. msg/client_content.py +43 -0
  23. msg/client_custodial.py +206 -0
  24. msg/client_market.py +104 -0
  25. msg/client_recovery.py +186 -0
  26. msg/client_secrets.py +58 -0
  27. msg/client_tokens.py +99 -0
  28. msg/client_upgrade.py +188 -0
  29. msg/config.py +318 -0
  30. msg/constants.py +11 -0
  31. msg/core/__init__.py +0 -0
  32. msg/core/batching.py +23 -0
  33. msg/core/codec.py +253 -0
  34. msg/core/contracts.py +128 -0
  35. msg/core/cursors.py +68 -0
  36. msg/core/email_address.py +33 -0
  37. msg/core/errors.py +22 -0
  38. msg/core/events.py +6 -0
  39. msg/core/execution_ports.py +20 -0
  40. msg/core/executor.py +193 -0
  41. msg/core/models.py +517 -0
  42. msg/core/packet.py +71 -0
  43. msg/core/permissions.py +14 -0
  44. msg/core/query.py +44 -0
  45. msg/core/read_query.py +60 -0
  46. msg/core/registry.py +173 -0
  47. msg/core/requests.py +58 -0
  48. msg/core/schema_policy.py +28 -0
  49. msg/core/schemas.py +30 -0
  50. msg/core/tags.py +21 -0
  51. msg/core/template_dsl.py +94 -0
  52. msg/core/text_patch.py +283 -0
  53. msg/core/tool_execution.py +28 -0
  54. msg/core/transfer.py +183 -0
  55. msg/daemon.py +255 -0
  56. msg/data/__init__.py +0 -0
  57. msg/data/bootstrap.json +72 -0
  58. msg/data/favicon.png +0 -0
  59. msg/data/logo-dark.svg +5 -0
  60. msg/data/logo.svg +5 -0
  61. msg/data/recovery-checkpoint.example.json +1 -0
  62. msg/data/recovery-checkpoint.schema.json +118 -0
  63. msg/data/shortcodes.json +1 -0
  64. msg/data/system/AGENTS.md +6 -0
  65. msg/data/system/rules/_index.md +17 -0
  66. msg/data/system/rules/auth.md +6 -0
  67. msg/data/system/rules/files.md +6 -0
  68. msg/data/system/rules/identity.md +6 -0
  69. msg/data/system/rules/protocol.md +6 -0
  70. msg/data/system/rules/read-write.md +6 -0
  71. msg/data/system/rules/recovery.md +6 -0
  72. msg/data/system/rules/security.md +10 -0
  73. msg/data/system/rules/topics.md +6 -0
  74. msg/extensions/__init__.py +1 -0
  75. msg/extensions/hosting.py +259 -0
  76. msg/extensions/keystore.py +96 -0
  77. msg/extensions/repositories.py +769 -0
  78. msg/extensions/rss.py +67 -0
  79. msg/extensions/ssh.py +264 -0
  80. msg/extensions/ssh_git.py +300 -0
  81. msg/extensions/tools.py +150 -0
  82. msg/hosting_runtime.py +138 -0
  83. msg/market/__init__.py +1 -0
  84. msg/market/arbitration.py +481 -0
  85. msg/market/delivery.py +338 -0
  86. msg/market/delivery_notifications.py +110 -0
  87. msg/market/delivery_targets.py +76 -0
  88. msg/market/email.py +100 -0
  89. msg/market/escrow.py +345 -0
  90. msg/market/orders.py +218 -0
  91. msg/market/policy.py +147 -0
  92. msg/market/rationale.py +91 -0
  93. msg/market/references.py +19 -0
  94. msg/market/targets.py +157 -0
  95. msg/plugins/__init__.py +28 -0
  96. msg/plugins/achievements.py +316 -0
  97. msg/plugins/batch.py +32 -0
  98. msg/plugins/bounty.py +366 -0
  99. msg/plugins/collaboration.py +261 -0
  100. msg/plugins/common.py +217 -0
  101. msg/plugins/communication.py +853 -0
  102. msg/plugins/content.py +855 -0
  103. msg/plugins/custodial_lifecycle.py +224 -0
  104. msg/plugins/delivery.py +230 -0
  105. msg/plugins/discovery.py +1266 -0
  106. msg/plugins/discussion.py +162 -0
  107. msg/plugins/extensions.py +10 -0
  108. msg/plugins/following.py +78 -0
  109. msg/plugins/hosting_capacity.py +52 -0
  110. msg/plugins/identity.py +1654 -0
  111. msg/plugins/money.py +221 -0
  112. msg/plugins/offers.py +183 -0
  113. msg/plugins/orders.py +273 -0
  114. msg/plugins/recovery.py +376 -0
  115. msg/plugins/schemas.py +5 -0
  116. msg/plugins/sharing.py +300 -0
  117. msg/plugins/store.py +249 -0
  118. msg/plugins/system.py +75 -0
  119. msg/plugins/transfer.py +249 -0
  120. msg/py.typed +0 -0
  121. msg/security/__init__.py +0 -0
  122. msg/security/age_keys.py +112 -0
  123. msg/security/authentication.py +141 -0
  124. msg/security/authorization.py +300 -0
  125. msg/security/backup_retirement.py +135 -0
  126. msg/security/capabilities.py +157 -0
  127. msg/security/certificates.py +183 -0
  128. msg/security/crypto.py +102 -0
  129. msg/security/custodial_migration.py +292 -0
  130. msg/security/custody_history.py +146 -0
  131. msg/security/network.py +81 -0
  132. msg/security/policy.py +67 -0
  133. msg/security/quarantine.py +19 -0
  134. msg/security/root_files.py +67 -0
  135. msg/security/rotation_journal.py +60 -0
  136. msg/security/sealed_box.py +70 -0
  137. msg/security/sharing_policy.py +29 -0
  138. msg/security/token_delivery.py +93 -0
  139. msg/security/vault.py +186 -0
  140. msg/storage/__init__.py +0 -0
  141. msg/storage/capacity.py +100 -0
  142. msg/storage/custodial_migration.py +22 -0
  143. msg/storage/git.py +435 -0
  144. msg/storage/ledger_migration.py +307 -0
  145. msg/storage/market_migration.py +49 -0
  146. msg/storage/postgres.py +663 -0
  147. msg/storage/query.py +39 -0
  148. msg/storage/read_only.py +126 -0
  149. msg/storage/session.py +403 -0
  150. msg/storage/sqlite.py +360 -0
  151. msg/storage/topic_event_migration.py +26 -0
  152. msg/storage/valkey_bus.py +45 -0
  153. msg/transports/__init__.py +0 -0
  154. msg/transports/client.py +183 -0
  155. msg/transports/dictionary.py +387 -0
  156. msg/transports/graphql.py +75 -0
  157. msg/transports/http.py +72 -0
  158. msg/transports/http_routes.py +1577 -0
  159. msg/transports/mcp.py +127 -0
  160. msg/transports/packet.py +42 -0
  161. msg/transports/read_tree_path.py +65 -0
  162. msg/transports/stdio.py +36 -0
  163. msg/transports/url_safety.py +123 -0
  164. msg/tui.py +368 -0
  165. msg/workers/__init__.py +1 -0
  166. msg/workers/effects.py +406 -0
  167. msg/workers/leases.py +19 -0
  168. msg/workers/mail.py +63 -0
  169. msg/workers/maintenance.py +310 -0
  170. msg/workers/sandbox.py +74 -0
  171. msg/workers/sandbox_child.py +189 -0
  172. msg/workers/webhook.py +160 -0
  173. msgctl-0.1.0a1.dist-info/METADATA +96 -0
  174. msgctl-0.1.0a1.dist-info/RECORD +176 -0
  175. msgctl-0.1.0a1.dist-info/WHEEL +4 -0
  176. msgctl-0.1.0a1.dist-info/entry_points.txt +4 -0
@@ -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()}