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/plugins/schemas.py
ADDED
msg/plugins/sharing.py
ADDED
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
"""Revocable, direct-subject read grants for one resource at a time."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from datetime import timedelta
|
|
5
|
+
import hashlib
|
|
6
|
+
import hmac
|
|
7
|
+
|
|
8
|
+
from msg.constants import ROOT_SUBJECT
|
|
9
|
+
from msg.core.codec import b64, canonical, loads, parse_time, unb64, wire
|
|
10
|
+
from msg.core.errors import Failure, require
|
|
11
|
+
from msg.core.models import HandlerOutput, ResourceRef
|
|
12
|
+
from msg.plugins.common import check_access, new_id, registration, resolve
|
|
13
|
+
from msg.security.sharing_policy import share_target_error
|
|
14
|
+
from msg.plugins.schemas import IDENTIFIER, STRING, obj
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
MAX_GRANT_TTL = timedelta(days=30)
|
|
18
|
+
MAX_LINK_BYTES = 65536
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _link_public(row):
|
|
22
|
+
return dict(zip(('id', 'resource_id', 'grantor', 'created_at', 'expires_at',
|
|
23
|
+
'revoked_at'), row))
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _links_enabled(tx):
|
|
27
|
+
require(tx.setting('share_links_enabled', False) is True, 'share_links_disabled')
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _verifier(token):
|
|
31
|
+
try:
|
|
32
|
+
raw = unb64(token, limit=64)
|
|
33
|
+
except (Failure, ValueError):
|
|
34
|
+
raise Failure('share_link_unavailable') from None
|
|
35
|
+
require(32 <= len(raw) <= 64, 'share_link_unavailable')
|
|
36
|
+
return 'sha256:'+hashlib.sha256(b'share-link-v1\0'+raw).hexdigest()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
async def _owned_resource(app, ctx, request, tx, value, *, mutation, owner=True):
|
|
40
|
+
rid = await resolve(tx, value)
|
|
41
|
+
resource = await tx.resource(rid)
|
|
42
|
+
require(ctx.principal.subject is not None and ctx.principal.actor == ctx.principal.subject,
|
|
43
|
+
'share_subject_required')
|
|
44
|
+
require((not owner or resource.owner == ctx.principal.subject) and resource.state == 'active',
|
|
45
|
+
'permission_denied')
|
|
46
|
+
require(not app.registry.resource_type(resource.type, resource.type_version).container,
|
|
47
|
+
'share_container_forbidden')
|
|
48
|
+
if owner:
|
|
49
|
+
await check_access(app, ctx, request, tx, rid, 'manage' if mutation else 'read')
|
|
50
|
+
else:
|
|
51
|
+
await app.authorizer.require_base(ctx.principal,
|
|
52
|
+
f'{request.operation}@{request.contract_version}',rid,tx)
|
|
53
|
+
chain = (*await tx.ancestors(rid), resource)
|
|
54
|
+
error = share_target_error(app.registry, resource, chain, tx)
|
|
55
|
+
require(error is None, error or 'share_forbidden_resource')
|
|
56
|
+
return resource
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
async def _revocable_resource(app, ctx, request, tx, value, *, owner=True):
|
|
60
|
+
"""Return (resource, live); a dormant target must not block narrowing access.
|
|
61
|
+
|
|
62
|
+
Archived/moved targets make a grant dormant, not revoked: restoring the
|
|
63
|
+
target would revive it, so revocation stays available under the ceiling.
|
|
64
|
+
"""
|
|
65
|
+
rid = await resolve(tx, value)
|
|
66
|
+
resource = await tx.resource(rid)
|
|
67
|
+
chain = (*await tx.ancestors(rid), resource)
|
|
68
|
+
if share_target_error(app.registry, resource, chain, tx) is None:
|
|
69
|
+
return await _owned_resource(app, ctx, request, tx, rid, mutation=True,
|
|
70
|
+
owner=owner), True
|
|
71
|
+
require(ctx.principal.subject is not None and ctx.principal.actor == ctx.principal.subject,
|
|
72
|
+
'share_subject_required')
|
|
73
|
+
require(not owner or resource.owner == ctx.principal.subject, 'permission_denied')
|
|
74
|
+
await app.authorizer.require_base(ctx.principal,
|
|
75
|
+
f'{request.operation}@{request.contract_version}', rid, tx)
|
|
76
|
+
return resource, False
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _public(row):
|
|
80
|
+
return dict(zip(('id', 'resource_id', 'grantor', 'grantee', 'created_at', 'expires_at',
|
|
81
|
+
'revoked_at'), row))
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _public_v2(row, now):
|
|
85
|
+
result=dict(zip(('id','resource_id','grantor','grantee','grantee_kind','parent_id',
|
|
86
|
+
'operations','constraints','allow_reshare','created_at','expires_at',
|
|
87
|
+
'revoked_at'),row))
|
|
88
|
+
result['operations']=loads(result['operations'])
|
|
89
|
+
result['constraints']=loads(result['constraints'])
|
|
90
|
+
result['allow_reshare']=bool(result['allow_reshare'])
|
|
91
|
+
result['status']='revoked' if result['revoked_at'] else (
|
|
92
|
+
'expired' if parse_time(result['expires_at'])<=now else 'active')
|
|
93
|
+
return result
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
_V2_COLUMNS='''id,resource_id,grantor,grantee,grantee_kind,parent_id,operations,
|
|
97
|
+
constraints,allow_reshare,created_at,expires_at,revoked_at'''
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def install(app):
|
|
101
|
+
op, finish = registration(app, 'sharing', ('identity',))
|
|
102
|
+
|
|
103
|
+
@op('sharing.grant', obj({'resource': IDENTIFIER, 'grantee': IDENTIFIER,
|
|
104
|
+
'expires_at': STRING}, ('resource', 'grantee', 'expires_at')))
|
|
105
|
+
async def grant(ctx, request, tx):
|
|
106
|
+
a = request.arguments
|
|
107
|
+
resource = await _owned_resource(app, ctx, request, tx, a['resource'], mutation=True)
|
|
108
|
+
grantee = await resolve(tx, a['grantee'])
|
|
109
|
+
await tx.subject(grantee)
|
|
110
|
+
require(grantee not in {resource.owner, ROOT_SUBJECT}, 'invalid_share_grantee')
|
|
111
|
+
expires = parse_time(a['expires_at'])
|
|
112
|
+
require(ctx.now < expires <= ctx.now + MAX_GRANT_TTL, 'invalid_share_expiry')
|
|
113
|
+
tx.execute('''UPDATE share_grants SET revoked_at=?
|
|
114
|
+
WHERE resource_id=? AND grantee=? AND revoked_at IS NULL AND expires_at<=?''',
|
|
115
|
+
(wire(ctx.now), resource.id, grantee, wire(ctx.now)), write=True)
|
|
116
|
+
require(tx.one('''SELECT 1 FROM share_grants
|
|
117
|
+
WHERE resource_id=? AND grantee=? AND revoked_at IS NULL''',
|
|
118
|
+
(resource.id, grantee)) is None, 'share_already_active')
|
|
119
|
+
gid = new_id('share')
|
|
120
|
+
tx.execute('''INSERT INTO share_grants
|
|
121
|
+
(id,resource_id,grantor,grantee,created_at,expires_at,revoked_at)
|
|
122
|
+
VALUES (?,?,?,?,?,?,NULL)''',
|
|
123
|
+
(gid, resource.id, resource.owner, grantee, wire(ctx.now), wire(expires)), write=True)
|
|
124
|
+
tx.set_setting('authorization_epoch',tx.setting('authorization_epoch',0)+1)
|
|
125
|
+
return HandlerOutput(data={'grant': _public((gid, resource.id, resource.owner,
|
|
126
|
+
grantee, wire(ctx.now), wire(expires), None))})
|
|
127
|
+
|
|
128
|
+
@op('sharing.revoke', obj({'grant_id': IDENTIFIER}, ('grant_id',)))
|
|
129
|
+
async def revoke(ctx, request, tx):
|
|
130
|
+
gid = request.arguments['grant_id']
|
|
131
|
+
row = tx.one('''SELECT id,resource_id,grantor,grantee,created_at,expires_at,revoked_at
|
|
132
|
+
FROM share_grants WHERE id=?''', (gid,))
|
|
133
|
+
require(row is not None, 'share_not_found')
|
|
134
|
+
resource, _ = await _revocable_resource(app, ctx, request, tx, row[1])
|
|
135
|
+
require(row[2] == resource.owner, 'permission_denied')
|
|
136
|
+
if row[6] is None:
|
|
137
|
+
tx.execute('UPDATE share_grants SET revoked_at=? WHERE id=?',
|
|
138
|
+
(wire(ctx.now), gid), write=True)
|
|
139
|
+
tx.set_setting('authorization_epoch',tx.setting('authorization_epoch',0)+1)
|
|
140
|
+
row = (*row[:6], wire(ctx.now))
|
|
141
|
+
return HandlerOutput(data={'grant': _public(row)})
|
|
142
|
+
|
|
143
|
+
@op('sharing.list', obj({'resource': IDENTIFIER}, ('resource',)), effect='read')
|
|
144
|
+
async def list_grants(ctx, request, tx):
|
|
145
|
+
resource = await _owned_resource(app, ctx, request, tx,
|
|
146
|
+
request.arguments['resource'], mutation=False)
|
|
147
|
+
rows = tx.rows('''SELECT id,resource_id,grantor,grantee,created_at,expires_at,revoked_at
|
|
148
|
+
FROM share_grants WHERE resource_id=? AND grantor=? ORDER BY created_at,id''',
|
|
149
|
+
(resource.id, resource.owner))
|
|
150
|
+
return HandlerOutput(data={'grants': [_public(row) for row in rows]})
|
|
151
|
+
|
|
152
|
+
@op('sharing.grant',obj({'resource':IDENTIFIER,'grantee':IDENTIFIER,
|
|
153
|
+
'grantee_kind':{'enum':['user','group']},'operations':{'type':'array',
|
|
154
|
+
'items':{'enum':['read']},'minItems':1,'uniqueItems':True},
|
|
155
|
+
'expires_at':STRING,'allow_reshare':{'type':'boolean'},
|
|
156
|
+
'constraints':obj(), 'source_grant_id':IDENTIFIER},
|
|
157
|
+
('resource','grantee','grantee_kind','operations','expires_at')),version=2)
|
|
158
|
+
async def grant_v2(ctx,request,tx):
|
|
159
|
+
a=request.arguments
|
|
160
|
+
resource=await _owned_resource(app,ctx,request,tx,a['resource'],mutation=True,
|
|
161
|
+
owner=False)
|
|
162
|
+
require(tuple(a['operations'])==('read',) and dict(a.get('constraints',{}))=={},
|
|
163
|
+
'invalid_share_operations')
|
|
164
|
+
grantee=await resolve(tx,a['grantee'])
|
|
165
|
+
if a['grantee_kind']=='user':
|
|
166
|
+
await tx.subject(grantee)
|
|
167
|
+
else:
|
|
168
|
+
target=await tx.resource(grantee)
|
|
169
|
+
require(target.type=='organization' and target.state=='active',
|
|
170
|
+
'invalid_share_grantee')
|
|
171
|
+
require(grantee not in {resource.owner,ROOT_SUBJECT},'invalid_share_grantee')
|
|
172
|
+
expires=parse_time(a['expires_at'])
|
|
173
|
+
require(ctx.now<expires<=ctx.now+MAX_GRANT_TTL,'invalid_share_expiry')
|
|
174
|
+
parent=a.get('source_grant_id')
|
|
175
|
+
if ctx.principal.subject==resource.owner:
|
|
176
|
+
require(parent is None,'share_source_not_needed')
|
|
177
|
+
await check_access(app,ctx,request,tx,resource.id,'manage')
|
|
178
|
+
else:
|
|
179
|
+
require(parent is not None,'share_source_required')
|
|
180
|
+
source=tx.one('''SELECT resource_id,expires_at,operations,constraints
|
|
181
|
+
FROM share_grants_v2 WHERE id=?''',(parent,))
|
|
182
|
+
require(source is not None and source[0]==resource.id and
|
|
183
|
+
await app.authorizer.share_source_active(resource,parent,
|
|
184
|
+
ctx.principal.subject,ctx.now,tx,reshare=True),'share_source_unavailable')
|
|
185
|
+
require(expires<=parse_time(source[1]) and
|
|
186
|
+
set(a['operations'])<=set(loads(source[2])) and
|
|
187
|
+
a.get('constraints',{})==loads(source[3]),'share_scope_exceeded')
|
|
188
|
+
gid=new_id('share')
|
|
189
|
+
tx.execute('''INSERT INTO share_grants_v2
|
|
190
|
+
(id,resource_id,grantor,grantee,grantee_kind,parent_id,operations,constraints,
|
|
191
|
+
allow_reshare,created_at,expires_at,revoked_at)
|
|
192
|
+
VALUES (?,?,?,?,?,?,?,?,?,?,?,NULL)''',
|
|
193
|
+
(gid,resource.id,ctx.principal.subject,grantee,a['grantee_kind'],parent,
|
|
194
|
+
canonical(a['operations']).decode(),canonical(a.get('constraints',{})).decode(),
|
|
195
|
+
int(a.get('allow_reshare',False)),wire(ctx.now),wire(expires)),write=True)
|
|
196
|
+
tx.set_setting('authorization_epoch',tx.setting('authorization_epoch',0)+1)
|
|
197
|
+
row=tx.one(f'SELECT {_V2_COLUMNS} FROM share_grants_v2 WHERE id=?',(gid,))
|
|
198
|
+
return HandlerOutput(data={'grant':_public_v2(row,ctx.now)})
|
|
199
|
+
|
|
200
|
+
@op('sharing.revoke',obj({'grant_id':IDENTIFIER},('grant_id',)),version=2)
|
|
201
|
+
async def revoke_v2(ctx,request,tx):
|
|
202
|
+
gid=request.arguments['grant_id']
|
|
203
|
+
row=tx.one(f'SELECT {_V2_COLUMNS} FROM share_grants_v2 WHERE id=?',(gid,))
|
|
204
|
+
require(row is not None,'share_not_found')
|
|
205
|
+
resource,live=await _revocable_resource(app,ctx,request,tx,row[1],owner=False)
|
|
206
|
+
require(ctx.principal.subject in {resource.owner,row[2]},'permission_denied')
|
|
207
|
+
if live and ctx.principal.subject==resource.owner:
|
|
208
|
+
await check_access(app,ctx,request,tx,resource.id,'manage')
|
|
209
|
+
if row[11] is None:
|
|
210
|
+
tx.execute('UPDATE share_grants_v2 SET revoked_at=? WHERE id=?',
|
|
211
|
+
(wire(ctx.now),gid),write=True)
|
|
212
|
+
tx.set_setting('authorization_epoch',tx.setting('authorization_epoch',0)+1)
|
|
213
|
+
row=(*row[:11],wire(ctx.now))
|
|
214
|
+
return HandlerOutput(data={'grant':_public_v2(row,ctx.now)})
|
|
215
|
+
|
|
216
|
+
@op('sharing.list',obj({'resource':IDENTIFIER},('resource',)),effect='read',version=2)
|
|
217
|
+
async def list_v2(ctx,request,tx):
|
|
218
|
+
resource=await _owned_resource(app,ctx,request,tx,
|
|
219
|
+
request.arguments['resource'],mutation=False,owner=False)
|
|
220
|
+
rows=tx.rows(f'''SELECT {_V2_COLUMNS} FROM share_grants_v2
|
|
221
|
+
WHERE resource_id=? AND grantor=? ORDER BY created_at,id''',
|
|
222
|
+
(resource.id,ctx.principal.subject))
|
|
223
|
+
return HandlerOutput(data={'grants':[_public_v2(row,ctx.now) for row in rows]})
|
|
224
|
+
|
|
225
|
+
@op('sharing.link_create', obj({'resource': IDENTIFIER,
|
|
226
|
+
'verifier': {'type':'string','pattern':'^sha256:[0-9a-f]{64}$'},
|
|
227
|
+
'expires_at': STRING}, ('resource','verifier','expires_at')), signature=True)
|
|
228
|
+
async def link_create(ctx, request, tx):
|
|
229
|
+
_links_enabled(tx)
|
|
230
|
+
require(ctx.principal.method=='signature' and
|
|
231
|
+
ctx.principal.actor==ctx.principal.subject, 'share_subject_required')
|
|
232
|
+
a=request.arguments
|
|
233
|
+
resource=await _owned_resource(app,ctx,request,tx,a['resource'],mutation=True)
|
|
234
|
+
expires=parse_time(a['expires_at'])
|
|
235
|
+
require(ctx.now<expires<=ctx.now+MAX_GRANT_TTL,'invalid_share_expiry')
|
|
236
|
+
require(resource.revision is not None,'share_link_content_required')
|
|
237
|
+
revision=await tx.revision(ResourceRef(id=resource.id))
|
|
238
|
+
require(revision.content.size<=MAX_LINK_BYTES,'share_link_content_too_large')
|
|
239
|
+
require(tx.one('SELECT 1 FROM share_links WHERE verifier=?',(a['verifier'],)) is None,
|
|
240
|
+
'share_link_verifier_in_use')
|
|
241
|
+
lid=new_id('link')
|
|
242
|
+
tx.execute('''INSERT INTO share_links
|
|
243
|
+
(id,resource_id,grantor,credential_id,verifier,created_at,expires_at,revoked_at)
|
|
244
|
+
VALUES (?,?,?,?,?,?,?,NULL)''',
|
|
245
|
+
(lid,resource.id,resource.owner,ctx.principal.credential_id,a['verifier'],
|
|
246
|
+
wire(ctx.now),wire(expires)),write=True)
|
|
247
|
+
tx.set_setting('authorization_epoch',tx.setting('authorization_epoch',0)+1)
|
|
248
|
+
return HandlerOutput(data={'link':_link_public((lid,resource.id,resource.owner,
|
|
249
|
+
wire(ctx.now),wire(expires),None)),
|
|
250
|
+
'endpoint':'/-/p/sharing.link_read',
|
|
251
|
+
'proof':'client-held token in JSON request body'})
|
|
252
|
+
|
|
253
|
+
@op('sharing.link_revoke',obj({'link_id':IDENTIFIER},('link_id',)),signature=True)
|
|
254
|
+
async def link_revoke(ctx,request,tx):
|
|
255
|
+
lid=request.arguments['link_id']
|
|
256
|
+
row=tx.one('''SELECT id,resource_id,grantor,created_at,expires_at,revoked_at
|
|
257
|
+
FROM share_links WHERE id=?''',(lid,))
|
|
258
|
+
require(row is not None,'share_link_unavailable')
|
|
259
|
+
resource=await _owned_resource(app,ctx,request,tx,row[1],mutation=True)
|
|
260
|
+
require(ctx.principal.method=='signature' and row[2]==resource.owner,
|
|
261
|
+
'permission_denied')
|
|
262
|
+
if row[5] is None:
|
|
263
|
+
tx.execute('UPDATE share_links SET revoked_at=? WHERE id=?',
|
|
264
|
+
(wire(ctx.now),lid),write=True)
|
|
265
|
+
tx.set_setting('authorization_epoch',tx.setting('authorization_epoch',0)+1)
|
|
266
|
+
row=(*row[:5],wire(ctx.now))
|
|
267
|
+
return HandlerOutput(data={'link':_link_public(row)})
|
|
268
|
+
|
|
269
|
+
@op('sharing.link_list',obj({'resource':IDENTIFIER},('resource',)),effect='read')
|
|
270
|
+
async def link_list(ctx,request,tx):
|
|
271
|
+
resource=await _owned_resource(app,ctx,request,tx,
|
|
272
|
+
request.arguments['resource'],mutation=False)
|
|
273
|
+
rows=tx.rows('''SELECT id,resource_id,grantor,created_at,expires_at,revoked_at
|
|
274
|
+
FROM share_links WHERE resource_id=? AND grantor=? ORDER BY created_at,id''',
|
|
275
|
+
(resource.id,resource.owner))
|
|
276
|
+
return HandlerOutput(data={'links':[_link_public(row) for row in rows]})
|
|
277
|
+
|
|
278
|
+
@op('sharing.link_read',obj({'link_id':IDENTIFIER,'token':STRING},
|
|
279
|
+
('link_id','token')),effect='read')
|
|
280
|
+
async def link_read(ctx,request,tx):
|
|
281
|
+
# Never accept signed identity as an alternative to the bearer source.
|
|
282
|
+
require(ctx.principal.method=='anonymous','share_link_unavailable')
|
|
283
|
+
_links_enabled(tx)
|
|
284
|
+
a=request.arguments
|
|
285
|
+
verifier=_verifier(a['token'])
|
|
286
|
+
row=tx.one('''SELECT resource_id,grantor,credential_id,verifier,expires_at,revoked_at
|
|
287
|
+
FROM share_links WHERE id=?''',(a['link_id'],))
|
|
288
|
+
require(row is not None and hmac.compare_digest(row[3],verifier) and
|
|
289
|
+
row[5] is None and parse_time(row[4])>ctx.now,'share_link_unavailable')
|
|
290
|
+
resource=await tx.resource(row[0])
|
|
291
|
+
chain=(*await tx.ancestors(resource.id),resource)
|
|
292
|
+
require(await app.authorizer.share_link_read_allowed(row[1],row[2],resource,
|
|
293
|
+
chain,ctx.now,tx),'share_link_unavailable')
|
|
294
|
+
revision=await tx.revision(ResourceRef(id=resource.id))
|
|
295
|
+
require(revision.content.size<=MAX_LINK_BYTES,'share_link_unavailable')
|
|
296
|
+
body=await app.contents.read_bytes(revision.content,limit=MAX_LINK_BYTES)
|
|
297
|
+
return HandlerOutput(data={'id':resource.id,'revision':revision.id,
|
|
298
|
+
'media_type':revision.content.media_type,'data':b64(body)})
|
|
299
|
+
|
|
300
|
+
finish()
|
msg/plugins/store.py
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
"""A catalog and immutable deposits; no purchase or escrow operations live here."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from msg.core.codec import canonical, digest, loads, parse_time, wire
|
|
5
|
+
from msg.core.errors import Failure, require
|
|
6
|
+
from msg.core.models import HandlerOutput, ResourceRef, ResourceTypeSpec
|
|
7
|
+
from msg.plugins.common import (assert_generation, check_access, create_resource,
|
|
8
|
+
new_id, operation_id, registration, resolve,
|
|
9
|
+
revise_resource)
|
|
10
|
+
from msg.plugins.schemas import IDENTIFIER, INTEGER, REF, STRING, obj
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
KINDS = ('file', 'text', 'secret', 'bundle', 'entitlement', 'service')
|
|
14
|
+
MODES = ('managed_instant', 'sealed_manual', 'service')
|
|
15
|
+
STATES = ('draft', 'active', 'paused', 'closed')
|
|
16
|
+
MAX_REFS = 32
|
|
17
|
+
MAX_MANIFEST_BYTES = 65536
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _subject(ctx):
|
|
21
|
+
subject = ctx.principal.subject
|
|
22
|
+
require(subject is not None and ctx.principal.actor == subject and
|
|
23
|
+
ctx.principal.method == 'signature', 'signature_required')
|
|
24
|
+
return subject
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
async def _listing(app, ctx, request, tx, value, *, seller=False):
|
|
28
|
+
try:
|
|
29
|
+
rid = await resolve(tx, value)
|
|
30
|
+
resource = await tx.resource(rid)
|
|
31
|
+
require(resource.type == 'listing' and resource.parent == 't_store', 'listing_not_found')
|
|
32
|
+
await check_access(app, ctx, request, tx, rid, 'read')
|
|
33
|
+
if seller:
|
|
34
|
+
require(resource.owner == _subject(ctx), 'listing_not_found')
|
|
35
|
+
return resource
|
|
36
|
+
except Failure as exc:
|
|
37
|
+
if exc.code in {'not_found', 'permission_denied', 'credential_ceiling',
|
|
38
|
+
'certificate_gate', 'ancestor_inactive'}:
|
|
39
|
+
raise Failure('listing_not_found') from None
|
|
40
|
+
raise
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
async def _body(app, tx, resource, revision=None):
|
|
44
|
+
rev = await tx.revision(ResourceRef(id=resource.id, revision=revision))
|
|
45
|
+
body = loads(await app.contents.read_bytes(rev.content))
|
|
46
|
+
# Listings deposited before the sale/bounty split were all sale listings.
|
|
47
|
+
return {'mode': 'sale', **body}, rev
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _public(resource, body):
|
|
51
|
+
# A package identifier is not a capability; package payloads are seller-only.
|
|
52
|
+
return {'listing_id': resource.id, 'listing_revision': resource.revision,
|
|
53
|
+
'seller': resource.owner, **body,
|
|
54
|
+
'terms_revision': resource.revision,
|
|
55
|
+
'terms_digest': digest(body['terms'])}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _validate(body, now):
|
|
59
|
+
require(body['mode'] == 'sale', 'listing_mode_unsupported')
|
|
60
|
+
require(body['currency_id'] == 'primary', 'unsupported_currency')
|
|
61
|
+
require(type(body['price_minor']) is int and 0 < body['price_minor'] <= 10**18,
|
|
62
|
+
'invalid_price')
|
|
63
|
+
require(type(body['quantity']) is int and 0 < body['quantity'] <= 10**9,
|
|
64
|
+
'invalid_quantity')
|
|
65
|
+
require(len(canonical(body)) <= MAX_MANIFEST_BYTES, 'listing_too_large')
|
|
66
|
+
if body['expires_at'] is not None and body['state'] != 'closed':
|
|
67
|
+
require(parse_time(body['expires_at']) > now, 'listing_expired')
|
|
68
|
+
if body['delivery_mode'] == 'service':
|
|
69
|
+
require(body['item_kind'] == 'service' and body['package_ref'] is None,
|
|
70
|
+
'invalid_delivery_mode')
|
|
71
|
+
else:
|
|
72
|
+
require(body['item_kind'] != 'service', 'invalid_delivery_mode')
|
|
73
|
+
if body['state'] == 'active' and body['delivery_mode'] == 'managed_instant':
|
|
74
|
+
require(body['package_ref'] is not None, 'package_required')
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
async def _package_row(tx, package_id):
|
|
78
|
+
return tx.one('''SELECT id,listing_id,listing_revision,seller,revision,kind,manifest,
|
|
79
|
+
payload_refs,digest,total_size,delivery_mode,deposited_at FROM store_packages WHERE id=?''',
|
|
80
|
+
(package_id,))
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _package_public(row):
|
|
84
|
+
return dict(zip(('id', 'listing_id', 'listing_revision', 'seller', 'revision',
|
|
85
|
+
'kind', 'manifest', 'payload_refs', 'digest', 'total_size',
|
|
86
|
+
'delivery_mode', 'deposited_at'),
|
|
87
|
+
(*row[:6], loads(row[6]), loads(row[7]), *row[8:])))
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def install(app):
|
|
91
|
+
op, finish = registration(app, 'store', ('content',))
|
|
92
|
+
listing_fields = {
|
|
93
|
+
'name': {'type':'string','minLength':1,'maxLength':120},
|
|
94
|
+
'mode': {'enum': ['sale', 'bounty']},
|
|
95
|
+
'item_kind': {'enum': list(KINDS)},
|
|
96
|
+
'price_minor': INTEGER,
|
|
97
|
+
'currency_id': IDENTIFIER,
|
|
98
|
+
'quantity': INTEGER,
|
|
99
|
+
'delivery_mode': {'enum': list(MODES)},
|
|
100
|
+
'package_ref': {'type':['string','null']},
|
|
101
|
+
'escrow_policy': {'type':'string','minLength':1,'maxLength':80},
|
|
102
|
+
'dispute_policy': {'type':'string','minLength':1,'maxLength':80},
|
|
103
|
+
'terms': {'type':'string','minLength':1,'maxLength':16384},
|
|
104
|
+
'expires_at': {'type':['string','null']},
|
|
105
|
+
'state': {'enum': list(STATES)},
|
|
106
|
+
}
|
|
107
|
+
creation_required = ('name', 'item_kind', 'price_minor', 'currency_id', 'quantity',
|
|
108
|
+
'delivery_mode', 'escrow_policy', 'dispute_policy', 'terms')
|
|
109
|
+
|
|
110
|
+
create_fields = {key:value for key,value in listing_fields.items()
|
|
111
|
+
if key not in {'state','package_ref'}}
|
|
112
|
+
@op('store.listing_create', obj(create_fields, creation_required), signature=True)
|
|
113
|
+
async def listing_create(ctx, request, tx):
|
|
114
|
+
seller = _subject(ctx)
|
|
115
|
+
await app.authorizer.require_base(ctx.principal, operation_id(request), seller, tx)
|
|
116
|
+
await check_access(app, ctx, request, tx, 't_store', 'create')
|
|
117
|
+
args = request.arguments
|
|
118
|
+
body = {key: args.get(key) for key in create_fields if key != 'name'}
|
|
119
|
+
body['mode'] = args.get('mode', 'sale')
|
|
120
|
+
body['package_ref'] = None
|
|
121
|
+
body['expires_at'] = args.get('expires_at')
|
|
122
|
+
body['state'] = 'draft'
|
|
123
|
+
_validate(body, ctx.now)
|
|
124
|
+
resource = await create_resource(app, ctx, request, tx, parent='t_store',
|
|
125
|
+
type='listing', name=args['name'], body=canonical(body),
|
|
126
|
+
media_type='application/json', mode=0o644)
|
|
127
|
+
require(resource.owner == seller, 'seller_mismatch')
|
|
128
|
+
return HandlerOutput(resources=(ResourceRef(id=resource.id, revision=resource.revision),),
|
|
129
|
+
data={'listing': _public(resource, body),
|
|
130
|
+
'generation': resource.generation})
|
|
131
|
+
|
|
132
|
+
@op('store.listing_update', obj({'id':IDENTIFIER, 'price_minor':INTEGER,
|
|
133
|
+
'quantity':INTEGER, 'terms':listing_fields['terms'], 'state':listing_fields['state'],
|
|
134
|
+
'expires_at':listing_fields['expires_at'], 'package_ref':listing_fields['package_ref']},
|
|
135
|
+
('id',)), signature=True)
|
|
136
|
+
async def listing_update(ctx, request, tx):
|
|
137
|
+
resource = await _listing(app, ctx, request, tx, request.arguments['id'], seller=True)
|
|
138
|
+
await check_access(app, ctx, request, tx, resource.id, 'write')
|
|
139
|
+
await assert_generation(request, resource)
|
|
140
|
+
old, _ = await _body(app, tx, resource)
|
|
141
|
+
require(old['mode'] == 'sale', 'listing_mode_unsupported')
|
|
142
|
+
updates = {key:value for key,value in request.arguments.items() if key != 'id'}
|
|
143
|
+
require(bool(updates), 'empty_listing_update')
|
|
144
|
+
body = {**old, **updates}
|
|
145
|
+
require(old['state'] != 'closed' or body['state'] == 'closed', 'listing_closed')
|
|
146
|
+
if body['package_ref'] is not None:
|
|
147
|
+
row = await _package_row(tx, body['package_ref'])
|
|
148
|
+
require(row is not None and row[1] == resource.id and row[3] == resource.owner and
|
|
149
|
+
row[5] == body['item_kind'] and row[10] == body['delivery_mode'],
|
|
150
|
+
'package_not_found')
|
|
151
|
+
_validate(body, ctx.now)
|
|
152
|
+
updated = await revise_resource(app, ctx, request, tx, resource, canonical(body),
|
|
153
|
+
'application/json')
|
|
154
|
+
return HandlerOutput(resources=(ResourceRef(id=updated.id, revision=updated.revision),),
|
|
155
|
+
data={'listing': _public(updated, body),
|
|
156
|
+
'generation':updated.generation})
|
|
157
|
+
|
|
158
|
+
@op('store.listing_get', obj({'id':IDENTIFIER, 'revision':IDENTIFIER}, ('id',)),
|
|
159
|
+
effect='read')
|
|
160
|
+
async def listing_get(ctx, request, tx):
|
|
161
|
+
resource = await _listing(app, ctx, request, tx, request.arguments['id'])
|
|
162
|
+
revision = request.arguments.get('revision')
|
|
163
|
+
body, rev = await _body(app, tx, resource, revision)
|
|
164
|
+
result = _public(resource, body)
|
|
165
|
+
if body['mode'] == 'bounty':
|
|
166
|
+
from msg.plugins.bounty import projection
|
|
167
|
+
live = projection(tx,resource.id,ctx.now)
|
|
168
|
+
result['current_state'] = live['state']
|
|
169
|
+
result['pause_reason'] = live['pause_reason']
|
|
170
|
+
result['current_budget_minor'] = live['budget_minor']
|
|
171
|
+
result['escrow_balance_minor'] = live['escrow_balance_minor']
|
|
172
|
+
result['paid_claims'] = live['paid_claims']
|
|
173
|
+
if revision is None:
|
|
174
|
+
result['state'] = live['state']
|
|
175
|
+
result['budget_minor'] = live['budget_minor']
|
|
176
|
+
result['listing_revision'] = rev.id
|
|
177
|
+
result['terms_revision'] = rev.id
|
|
178
|
+
return HandlerOutput(resources=(ResourceRef(id=resource.id, revision=rev.id),),
|
|
179
|
+
data={'listing':result})
|
|
180
|
+
|
|
181
|
+
@op('store.package_deposit', obj({'listing_id':IDENTIFIER,
|
|
182
|
+
'listing_revision':IDENTIFIER,
|
|
183
|
+
'manifest':{'type':'object'},
|
|
184
|
+
'payload_refs':{'type':'array','items':REF,'minItems':1,'maxItems':MAX_REFS}},
|
|
185
|
+
('listing_id','listing_revision','manifest','payload_refs')), signature=True)
|
|
186
|
+
async def package_deposit(ctx, request, tx):
|
|
187
|
+
from msg.plugins.communication import direct_ancestor
|
|
188
|
+
resource = await _listing(app, ctx, request, tx, request.arguments['listing_id'], seller=True)
|
|
189
|
+
body, _ = await _body(app, tx, resource)
|
|
190
|
+
require(body['mode'] == 'sale', 'listing_mode_unsupported')
|
|
191
|
+
require(resource.revision == request.arguments['listing_revision'],
|
|
192
|
+
'listing_revision_conflict')
|
|
193
|
+
require(body['delivery_mode'] == 'managed_instant' and
|
|
194
|
+
body['item_kind'] in {'file','bundle','text'} and body['state'] != 'closed',
|
|
195
|
+
'package_mode_unsupported')
|
|
196
|
+
manifest = request.arguments['manifest']
|
|
197
|
+
require(len(canonical(manifest)) <= MAX_MANIFEST_BYTES, 'manifest_too_large')
|
|
198
|
+
refs=[]
|
|
199
|
+
blobs=[]
|
|
200
|
+
total=0
|
|
201
|
+
for ref in request.arguments['payload_refs']:
|
|
202
|
+
require(ref['revision'] is not None, 'payload_revision_required')
|
|
203
|
+
rid=await resolve(tx,ref['id'])
|
|
204
|
+
item=await tx.resource(rid)
|
|
205
|
+
require(item.owner == resource.owner and item.state == 'active' and
|
|
206
|
+
item.type in {'file','attachment'}, 'payload_not_owned')
|
|
207
|
+
require(await direct_ancestor(tx, rid) is None, 'payload_private_conversation')
|
|
208
|
+
await check_access(app, ctx, request, tx, rid, 'read')
|
|
209
|
+
revision=await tx.revision(ResourceRef(id=rid,revision=ref['revision']))
|
|
210
|
+
if body['item_kind'] == 'text':
|
|
211
|
+
require(revision.content.media_type.startswith('text/'), 'text_payload_required')
|
|
212
|
+
blobs.append(revision.content)
|
|
213
|
+
total+=revision.content.size
|
|
214
|
+
require(total <= 1024**4, 'package_too_large')
|
|
215
|
+
refs.append({'id':rid,'revision':revision.id,'blob':wire(revision.content)})
|
|
216
|
+
require(len({(r['id'],r['revision']) for r in refs}) == len(refs),
|
|
217
|
+
'duplicate_payload_ref')
|
|
218
|
+
require(body['item_kind'] == 'bundle' or len(refs) == 1,
|
|
219
|
+
'file_package_single_payload')
|
|
220
|
+
package_id=new_id('pkg')
|
|
221
|
+
package_revision=new_id('pv')
|
|
222
|
+
snapshot={'seller':resource.owner,'listing_id':resource.id,
|
|
223
|
+
'listing_revision':resource.revision,'revision':package_revision,
|
|
224
|
+
'kind':body['item_kind'],'manifest':manifest,'payload_refs':refs,
|
|
225
|
+
'total_size':total,'delivery_mode':'managed_instant'}
|
|
226
|
+
package_digest=digest(snapshot)
|
|
227
|
+
for blob in blobs:
|
|
228
|
+
tx.on_rollback(lambda blob=blob: app.contents.unpin(blob, package_revision))
|
|
229
|
+
await app.contents.pin(blob, package_revision)
|
|
230
|
+
tx.execute('''INSERT INTO store_packages
|
|
231
|
+
(id,listing_id,listing_revision,seller,revision,kind,manifest,payload_refs,
|
|
232
|
+
digest,total_size,delivery_mode,deposited_at)
|
|
233
|
+
VALUES (?,?,?,?,?,?,?,?,?,?,?,?)''',
|
|
234
|
+
(package_id,resource.id,resource.revision,resource.owner,package_revision,
|
|
235
|
+
body['item_kind'],canonical(manifest).decode(),canonical(refs).decode(),
|
|
236
|
+
package_digest,total,'managed_instant',wire(ctx.now)),write=True)
|
|
237
|
+
return HandlerOutput(resources=(ResourceRef(id=resource.id,revision=resource.revision),),
|
|
238
|
+
data={'package':{'id':package_id,'revision':package_revision,
|
|
239
|
+
'digest':package_digest,'total_size':total}})
|
|
240
|
+
|
|
241
|
+
@op('store.package_get', obj({'id':IDENTIFIER}, ('id',)), effect='read')
|
|
242
|
+
async def package_get(ctx, request, tx):
|
|
243
|
+
row=await _package_row(tx,request.arguments['id'])
|
|
244
|
+
require(row is not None and row[3] == _subject(ctx), 'package_not_found')
|
|
245
|
+
await _listing(app, ctx, request, tx, row[1], seller=True)
|
|
246
|
+
return HandlerOutput(data={'package':_package_public(row)})
|
|
247
|
+
|
|
248
|
+
finish((ResourceTypeSpec(name='listing',version=1,container=False,
|
|
249
|
+
content_schema=None,operations=frozenset(),relations=frozenset()),))
|
msg/plugins/system.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Finite online administration: no paths, root keys, plugin code or shell commands."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
from msg.constants import ROOT_SPACE
|
|
4
|
+
from msg.core.codec import canonical, digest, wire
|
|
5
|
+
from msg.core.errors import require
|
|
6
|
+
from msg.core.models import AuditEvent, Event, EffectJob, HandlerOutput, ResourceRef
|
|
7
|
+
from msg.plugins.common import new_id, registration, operation_id
|
|
8
|
+
from msg.plugins.communication import event_id
|
|
9
|
+
from msg.plugins.schemas import obj, BOOLEAN, STRING
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
MAINTENANCE_ACTIONS_V1 = ('cleanup_expired', 'rebuild_search', 'collect_garbage')
|
|
13
|
+
MAINTENANCE_ACTIONS = (*MAINTENANCE_ACTIONS_V1, 'deliver_due_todos')
|
|
14
|
+
RUNTIME_SCHEMA = obj({'accept_writes':BOOLEAN, 'status_message':{'type':'string','maxLength':300},
|
|
15
|
+
'cleanup_enabled':BOOLEAN})
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def install(app):
|
|
19
|
+
op, finish = registration(app, 'system', ('identity','content'))
|
|
20
|
+
|
|
21
|
+
async def authorized(ctx, request, tx, capability):
|
|
22
|
+
require(await app.authorizer.has(ctx.principal, capability, operation_id(request), ROOT_SPACE, tx),
|
|
23
|
+
'capability_required')
|
|
24
|
+
|
|
25
|
+
@op('system.inspect', obj(), effect='read', signature=True)
|
|
26
|
+
async def inspect(ctx, request, tx):
|
|
27
|
+
await authorized(ctx, request, tx, 'system.inspect')
|
|
28
|
+
# Deliberately omits environment, credentials, filesystem paths and keys.
|
|
29
|
+
return HandlerOutput(data={'schema_version':tx.one('SELECT version FROM schema_version')[0],
|
|
30
|
+
'resources':tx.one('SELECT COUNT(*) FROM resources')[0],
|
|
31
|
+
'pending_jobs':tx.one("SELECT COUNT(*) FROM jobs WHERE state='pending'")[0],
|
|
32
|
+
'uncertain_jobs':tx.one("SELECT COUNT(*) FROM jobs WHERE state='uncertain'")[0],
|
|
33
|
+
'runtime':tx.setting('runtime_config',{'accept_writes':True,'cleanup_enabled':True})})
|
|
34
|
+
|
|
35
|
+
@op('system.config', obj({'values':RUNTIME_SCHEMA},('values',)), signature=True)
|
|
36
|
+
async def configure(ctx, request, tx):
|
|
37
|
+
await authorized(ctx, request, tx, 'system.config')
|
|
38
|
+
before = tx.setting('runtime_config', {'accept_writes':True,'cleanup_enabled':True})
|
|
39
|
+
after = {**before, **request.arguments['values']}
|
|
40
|
+
tx.set_setting('runtime_config', after)
|
|
41
|
+
event = Event(id=new_id('audit'), type='system.config', time=ctx.now, request_id=request.request_id,
|
|
42
|
+
actor=ctx.principal.actor, subject=ctx.principal.subject, resources=(ResourceRef(id=ROOT_SPACE),), data={})
|
|
43
|
+
await tx.append_audit(AuditEvent(event=event, authority=tuple(ResourceRef(id=c) for c in ctx.principal.certificates),
|
|
44
|
+
before_digest=digest(before), after_digest=digest(after), previous_digest=None, entry_digest='', result='configured'))
|
|
45
|
+
return HandlerOutput(data={'runtime':after})
|
|
46
|
+
|
|
47
|
+
@op('system.share_links_set',obj({'enabled':BOOLEAN},('enabled',)),signature=True)
|
|
48
|
+
async def share_links_set(ctx,request,tx):
|
|
49
|
+
await authorized(ctx,request,tx,'system.config')
|
|
50
|
+
before=bool(tx.setting('share_links_enabled',False))
|
|
51
|
+
after=request.arguments['enabled']
|
|
52
|
+
tx.set_setting('share_links_enabled',after)
|
|
53
|
+
event=Event(id=new_id('audit'),type='system.share_links_set',time=ctx.now,
|
|
54
|
+
request_id=request.request_id,actor=ctx.principal.actor,subject=ctx.principal.subject,
|
|
55
|
+
resources=(ResourceRef(id=ROOT_SPACE),),data={'enabled':after})
|
|
56
|
+
await tx.append_audit(AuditEvent(event=event,
|
|
57
|
+
authority=tuple(ResourceRef(id=cid) for cid in ctx.principal.certificates),
|
|
58
|
+
before_digest=digest({'enabled':before}),after_digest=digest({'enabled':after}),
|
|
59
|
+
previous_digest=None,entry_digest='',result='configured'))
|
|
60
|
+
return HandlerOutput(data={'enabled':after})
|
|
61
|
+
|
|
62
|
+
@op('system.maintenance', obj({'action':{'enum':list(MAINTENANCE_ACTIONS_V1)}},('action',)),
|
|
63
|
+
effect='external', signature=True)
|
|
64
|
+
@op('system.maintenance', obj({'action':{'enum':list(MAINTENANCE_ACTIONS)}},('action',)),
|
|
65
|
+
effect='external', signature=True, version=2)
|
|
66
|
+
async def maintenance(ctx, request, tx):
|
|
67
|
+
await authorized(ctx, request, tx, 'system.maintenance')
|
|
68
|
+
eid = event_id(request, ctx.principal.subject)
|
|
69
|
+
job = EffectJob(id=new_id('job'), event_id=eid, kind='maintenance', dedupe_key='maintenance:'+eid,
|
|
70
|
+
principal=ctx.principal, operation=request.operation,
|
|
71
|
+
arguments={'action':request.arguments['action'],'request_id':request.request_id},
|
|
72
|
+
state='pending', attempts=0, next_attempt_at=ctx.now, lease_until=None)
|
|
73
|
+
await tx.enqueue(job)
|
|
74
|
+
return HandlerOutput(data={'job_id':job.id,'state':'pending'})
|
|
75
|
+
finish()
|