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
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
"""Current owner/group/mode/ShareGrant/credential/certificate authorization path."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
from msg.constants import ROOT_SUBJECT,TOOLS_SPACE
|
|
4
|
+
from msg.core.errors import Failure,require
|
|
5
|
+
from msg.core.codec import loads,parse_time,wire
|
|
6
|
+
from msg.core.models import ResourceRef
|
|
7
|
+
from msg.security.sharing_policy import share_target_error
|
|
8
|
+
from msg.security.quarantine import active as quarantine_active, require_live_authority
|
|
9
|
+
from msg.security.policy import allows,grant_covers,scope_contains,CERTGATE
|
|
10
|
+
|
|
11
|
+
_WRITE_CHECKS={'write','create','remove','chmod','chgrp','chown','manage','certgate','purge','tool_use'}
|
|
12
|
+
_OVERRIDE={'read':'resource.read_override','list':'resource.read_override','traverse':'resource.read_override',
|
|
13
|
+
'write':'resource.write_override','chmod':'resource.chmod_override','chgrp':'resource.chgrp_override',
|
|
14
|
+
'chown':'resource.chown','purge':'resource.purge','tool_use':'tool.use'}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class AuthorizationService:
|
|
18
|
+
def __init__(self,registry,certificates):
|
|
19
|
+
self.registry,self.certificates=registry,certificates
|
|
20
|
+
|
|
21
|
+
async def grants(self,principal,session):
|
|
22
|
+
require_live_authority(session)
|
|
23
|
+
grants=[]
|
|
24
|
+
for cid in principal.certificates:
|
|
25
|
+
certificate=await self.certificates.validate(cid,session)
|
|
26
|
+
require(certificate.key_id==principal.credential_id and certificate.subject_id==principal.actor,
|
|
27
|
+
'certificate_subject_mismatch')
|
|
28
|
+
grants.extend(certificate.grants)
|
|
29
|
+
return tuple(grants)
|
|
30
|
+
|
|
31
|
+
async def has(self,principal,capability,operation,resource,session):
|
|
32
|
+
if principal.method=='local' and principal.subject==ROOT_SUBJECT:
|
|
33
|
+
return True
|
|
34
|
+
if not any([await grant_covers(g,capability,operation,resource,session) for g in principal.ceiling]):
|
|
35
|
+
return False
|
|
36
|
+
return any([await grant_covers(g,capability,operation,resource,session)
|
|
37
|
+
for g in await self.grants(principal,session)])
|
|
38
|
+
|
|
39
|
+
async def _ceiling(self,principal,operation,resource,session):
|
|
40
|
+
require_live_authority(session)
|
|
41
|
+
if principal.method=='anonymous':
|
|
42
|
+
return
|
|
43
|
+
allowed=any([operation in g.operations and await scope_contains(g.scope,resource,session)
|
|
44
|
+
for g in principal.ceiling])
|
|
45
|
+
if not allowed and operation in {'discovery.get@1','discovery.raw@1','job.get@1',
|
|
46
|
+
'transfer.open@1','transfer.part_get@1','transfer.status@1','transfer.seal@1','transfer.cancel@1'}:
|
|
47
|
+
output=session.setting('tool_output:'+resource)
|
|
48
|
+
if output and output['subject']==principal.subject:
|
|
49
|
+
allowed=await self.has(principal,'tool.use',operation,output['tool_id'],session)
|
|
50
|
+
require(allowed,'credential_ceiling')
|
|
51
|
+
if principal.actor!=principal.subject:
|
|
52
|
+
delegated=[]
|
|
53
|
+
for cid in principal.certificates:
|
|
54
|
+
cert=await self.certificates.validate(cid,session)
|
|
55
|
+
if cert.kind=='delegation':
|
|
56
|
+
for source in cert.authority_sources:
|
|
57
|
+
fact=await self.certificates.validate_authority(source,cert,session)
|
|
58
|
+
if fact['grantor']==principal.subject:
|
|
59
|
+
delegated.extend(cert.grants)
|
|
60
|
+
require(any([operation in g.operations and await scope_contains(g.scope,resource,session)
|
|
61
|
+
for g in delegated]),'delegation_scope')
|
|
62
|
+
|
|
63
|
+
async def ordinary(self,principal,operation,resource,session):
|
|
64
|
+
if principal.method in {'anonymous','local'}:
|
|
65
|
+
return True
|
|
66
|
+
from msg.security.capabilities import BASE_FAMILIES
|
|
67
|
+
return any([g.capability in BASE_FAMILIES and operation in g.operations and
|
|
68
|
+
await scope_contains(g.scope,resource,session) for g in principal.ceiling])
|
|
69
|
+
|
|
70
|
+
async def share_source_active(self,resource,grant_id,subject,now,session, *, reshare=False,
|
|
71
|
+
seen=frozenset()):
|
|
72
|
+
"""Resolve a bounded source chain; stored facts are not current authority.
|
|
73
|
+
|
|
74
|
+
Unsupported operations/constraints and malformed restored facts deny this
|
|
75
|
+
source only. Independent owner/group/certificate/grant sources survive.
|
|
76
|
+
"""
|
|
77
|
+
chain=(*await session.ancestors(resource.id),resource)
|
|
78
|
+
if share_target_error(self.registry,resource,chain,session):
|
|
79
|
+
return False
|
|
80
|
+
seen=set(seen)
|
|
81
|
+
child_expiry=None
|
|
82
|
+
while grant_id is not None:
|
|
83
|
+
if grant_id in seen or len(seen)>=16:
|
|
84
|
+
return False
|
|
85
|
+
seen.add(grant_id)
|
|
86
|
+
row=session.one('''SELECT resource_id,grantor,grantee,grantee_kind,parent_id,
|
|
87
|
+
operations,constraints,allow_reshare,expires_at,revoked_at,created_at
|
|
88
|
+
FROM share_grants_v2 WHERE id=?''',(grant_id,))
|
|
89
|
+
if row is None or row[0]!=resource.id or row[9] is not None:
|
|
90
|
+
return False
|
|
91
|
+
try:
|
|
92
|
+
expires=parse_time(row[8])
|
|
93
|
+
created=parse_time(row[10])
|
|
94
|
+
supported=loads(row[5])==['read'] and loads(row[6])=={}
|
|
95
|
+
except (Failure,ValueError,TypeError,OverflowError):
|
|
96
|
+
return False
|
|
97
|
+
if (not supported or created>now or expires<=now or expires<=created or
|
|
98
|
+
(child_expiry is not None and child_expiry>expires)):
|
|
99
|
+
return False
|
|
100
|
+
if reshare and row[7]!=1:
|
|
101
|
+
return False
|
|
102
|
+
if row[3]=='user':
|
|
103
|
+
if row[2]!=subject:
|
|
104
|
+
return False
|
|
105
|
+
elif row[3]=='group':
|
|
106
|
+
try:
|
|
107
|
+
group=await session.resource(row[2])
|
|
108
|
+
await session.organization(row[2])
|
|
109
|
+
except Failure as exc:
|
|
110
|
+
if exc.code in {'not_found','group_not_found'}:
|
|
111
|
+
return False
|
|
112
|
+
raise
|
|
113
|
+
if group.state!='active' or row[2] not in {
|
|
114
|
+
member.organization_id for member in await session.memberships(subject)}:
|
|
115
|
+
return False
|
|
116
|
+
else:
|
|
117
|
+
return False
|
|
118
|
+
if row[4] is None:
|
|
119
|
+
return row[1]==resource.owner
|
|
120
|
+
subject,grant_id,child_expiry,reshare=row[1],row[4],expires,True
|
|
121
|
+
return False
|
|
122
|
+
|
|
123
|
+
async def shared_read(self,principal,resource,chain,now,session):
|
|
124
|
+
"""A direct, live source for exactly one resource's read check."""
|
|
125
|
+
if principal.subject is None or principal.actor != principal.subject:
|
|
126
|
+
return False
|
|
127
|
+
if share_target_error(self.registry,resource,chain,session):
|
|
128
|
+
return False
|
|
129
|
+
row=session.one('''SELECT grantor,expires_at FROM share_grants
|
|
130
|
+
WHERE resource_id=? AND grantee=? AND revoked_at IS NULL''',
|
|
131
|
+
(resource.id,principal.subject))
|
|
132
|
+
if row and row[0]==resource.owner and parse_time(row[1])>now and resource.state=='active':
|
|
133
|
+
return True
|
|
134
|
+
if resource.state!='active':
|
|
135
|
+
return False
|
|
136
|
+
candidates=session.rows('''SELECT id FROM share_grants_v2
|
|
137
|
+
WHERE resource_id=? AND revoked_at IS NULL AND expires_at>?''',
|
|
138
|
+
(resource.id,wire(now)))
|
|
139
|
+
return any([await self.share_source_active(resource,row[0],principal.subject,now,session)
|
|
140
|
+
for row in candidates])
|
|
141
|
+
|
|
142
|
+
async def share_link_read_allowed(self,grantor,credential_id,resource,chain,now,session):
|
|
143
|
+
"""Recheck one bearer source without creating a principal or granting traversal.
|
|
144
|
+
|
|
145
|
+
The original signed owner's credential is a revocation boundary. A link
|
|
146
|
+
cannot borrow that principal for other reads or outlive its current scope.
|
|
147
|
+
"""
|
|
148
|
+
if quarantine_active(session):
|
|
149
|
+
return False
|
|
150
|
+
if resource.owner!=grantor or share_target_error(self.registry,resource,chain,session):
|
|
151
|
+
return False
|
|
152
|
+
memberships={m.organization_id for m in await session.memberships(grantor)}
|
|
153
|
+
if not allows(resource,grantor,memberships,'read') or any(
|
|
154
|
+
not allows(ancestor,grantor,memberships,'traverse') for ancestor in chain[:-1]):
|
|
155
|
+
return False
|
|
156
|
+
try:
|
|
157
|
+
credential=await session.credential(credential_id)
|
|
158
|
+
except Failure as exc:
|
|
159
|
+
if exc.code=='credential_not_found':
|
|
160
|
+
return False
|
|
161
|
+
raise
|
|
162
|
+
if (credential.subject_id!=grantor or credential.kind!='signing_key' or
|
|
163
|
+
credential.revoked_at is not None or credential.not_before>now or
|
|
164
|
+
(credential.expires_at is not None and credential.expires_at<=now)):
|
|
165
|
+
return False
|
|
166
|
+
return any([('sharing.link_create@1' in grant.operations and
|
|
167
|
+
await scope_contains(grant.scope,resource.id,session))
|
|
168
|
+
for grant in credential.ceiling])
|
|
169
|
+
|
|
170
|
+
async def require_base(self,principal,operation,resource,session):
|
|
171
|
+
await self._ceiling(principal,operation,resource,session)
|
|
172
|
+
require(await self.ordinary(principal,operation,resource,session),'credential_ceiling')
|
|
173
|
+
|
|
174
|
+
async def require(self,context,request,checks,session):
|
|
175
|
+
principal=context.principal
|
|
176
|
+
require_live_authority(session)
|
|
177
|
+
if principal.subject==ROOT_SUBJECT or principal.actor==ROOT_SUBJECT:
|
|
178
|
+
require(context.entry=='local_admin' and principal.method=='local','local_only')
|
|
179
|
+
memberships={m.organization_id for m in await session.memberships(principal.subject)} if principal.subject else set()
|
|
180
|
+
for check in checks:
|
|
181
|
+
resource=await session.resource(check.resource_id)
|
|
182
|
+
operation=check.operation
|
|
183
|
+
await self._ceiling(principal,operation,resource.id,session)
|
|
184
|
+
chain=(*await session.ancestors(resource.id),resource)
|
|
185
|
+
preview=next((marker for item in chain
|
|
186
|
+
for marker in (session.setting('hosting_preview:'+item.id),
|
|
187
|
+
session.setting('hosting_preview_file:'+item.id)) if marker),None)
|
|
188
|
+
if preview is not None:
|
|
189
|
+
require(principal.subject==preview['owner'] or
|
|
190
|
+
(principal.subject==ROOT_SUBJECT and context.entry=='local_admin'
|
|
191
|
+
and principal.method=='local'),'permission_denied')
|
|
192
|
+
if check.check in _WRITE_CHECKS and any(
|
|
193
|
+
item.id in {'r_agents','r_rules'} for item in chain):
|
|
194
|
+
require(False,'system_managed_resource')
|
|
195
|
+
if check.check in _WRITE_CHECKS and any(item.id=='t_last_will' for item in chain):
|
|
196
|
+
require(operation in {'identity.legacy_put@1','identity.legacy_put@2',
|
|
197
|
+
'identity.legacy_archive@1'} and
|
|
198
|
+
principal.subject==resource.owner,'legacy_directive_only')
|
|
199
|
+
if check.check in _WRITE_CHECKS and any(
|
|
200
|
+
parent.type=='user' and child.name in {'SOUL.md','AGENTS.md','notes','todos'}
|
|
201
|
+
for parent,child in zip(chain,chain[1:])):
|
|
202
|
+
sharing_notes=(operation in {'sharing.grant@1','sharing.grant@2','sharing.revoke@1',
|
|
203
|
+
'sharing.revoke@2',
|
|
204
|
+
'sharing.link_create@1','sharing.link_revoke@1'} and
|
|
205
|
+
all(child.name not in {'SOUL.md','AGENTS.md','todos'}
|
|
206
|
+
for parent,child in zip(chain,chain[1:]) if parent.type=='user'))
|
|
207
|
+
require((sharing_notes or operation in {'identity.personal_put@1','identity.note_put@1',
|
|
208
|
+
'identity.personal_put@2','identity.note_put@2',
|
|
209
|
+
'identity.soul_visibility@1','identity.note_archive@1',
|
|
210
|
+
'identity.note_restore@1','identity.todo_put@1',
|
|
211
|
+
'identity.todo_put@2',
|
|
212
|
+
'identity.todo_archive@1','identity.todo_restore@1'}) and
|
|
213
|
+
principal.subject==resource.owner,'personal_managed_resource')
|
|
214
|
+
if check.check in _WRITE_CHECKS and principal.subject is not None:
|
|
215
|
+
for ancestor in reversed(chain):
|
|
216
|
+
if ancestor.type!='topic':
|
|
217
|
+
continue
|
|
218
|
+
ban=session.one("SELECT expires_at FROM topic_bans WHERE topic=? AND subject=? AND status='active'",
|
|
219
|
+
(ancestor.id,principal.subject))
|
|
220
|
+
if ban is not None and (ban[0] is None or parse_time(ban[0])>context.now):
|
|
221
|
+
require(False,'topic_banned')
|
|
222
|
+
direct=None
|
|
223
|
+
for ancestor in chain:
|
|
224
|
+
direct=session.one('SELECT participant_a,participant_b,state,resource_id FROM dm_conversations WHERE resource_id=?',
|
|
225
|
+
(ancestor.id,))
|
|
226
|
+
if direct is not None:
|
|
227
|
+
break
|
|
228
|
+
if direct is not None:
|
|
229
|
+
# A direct conversation has two fixed participants. Neither file
|
|
230
|
+
# mode nor an override certificate may reveal it to a third party.
|
|
231
|
+
require(principal.subject in direct[:2], 'permission_denied')
|
|
232
|
+
if check.check in {'read','list','traverse'}:
|
|
233
|
+
continue
|
|
234
|
+
if (check.check=='create' and resource.id==direct[3] and
|
|
235
|
+
operation=='communication.dm_send@1' and direct[2]=='active'):
|
|
236
|
+
blocked=session.one('SELECT 1 FROM dm_blocks WHERE (blocker=? AND blocked=?) OR (blocker=? AND blocked=?)',
|
|
237
|
+
(direct[0],direct[1],direct[1],direct[0]))
|
|
238
|
+
require(blocked is None,'dm_blocked')
|
|
239
|
+
continue
|
|
240
|
+
if (check.check=='write' and resource.type=='post' and resource.owner==principal.subject and
|
|
241
|
+
operation=='content.post_edit@1'):
|
|
242
|
+
blocked=session.one('SELECT 1 FROM dm_blocks WHERE (blocker=? AND blocked=?) OR (blocker=? AND blocked=?)',
|
|
243
|
+
(direct[0],direct[1],direct[1],direct[0]))
|
|
244
|
+
require(direct[2]=='active' and blocked is None,'dm_blocked')
|
|
245
|
+
continue
|
|
246
|
+
require(False,'dm_controlled_resource')
|
|
247
|
+
# tool.use only reveals its scoped tool and the ancestors needed to reach it.
|
|
248
|
+
tool_access=resource.type=='tool' and await self.has(principal,'tool.use',operation,resource.id,session)
|
|
249
|
+
if resource.type=='tool':
|
|
250
|
+
require(tool_access,'tool_certificate_required')
|
|
251
|
+
shared_read=(check.check=='read' and await self.shared_read(
|
|
252
|
+
principal,resource,chain,context.now,session))
|
|
253
|
+
for ancestor in chain[:-1]:
|
|
254
|
+
readable=allows(ancestor,principal.subject,memberships,'traverse')
|
|
255
|
+
override=await self.has(principal,'resource.read_override',operation,ancestor.id,session)
|
|
256
|
+
minimal_tool=tool_access and ancestor.id==TOOLS_SPACE
|
|
257
|
+
require(readable or override or minimal_tool or shared_read,'permission_denied')
|
|
258
|
+
require(ancestor.state=='active','ancestor_inactive')
|
|
259
|
+
if check.check in _WRITE_CHECKS:
|
|
260
|
+
require(principal.subject is not None,'authentication_required')
|
|
261
|
+
for ancestor in chain:
|
|
262
|
+
if ancestor.mode&CERTGATE:
|
|
263
|
+
require(await self.has(principal,'resource.certified_write',operation,resource.id,session),
|
|
264
|
+
'certificate_gate')
|
|
265
|
+
if resource.type=='tool':
|
|
266
|
+
require(tool_access,'tool_certificate_required')
|
|
267
|
+
require(check.check in {'read','tool_use'},'tool_read_only')
|
|
268
|
+
continue
|
|
269
|
+
if resource.type=='csr' and check.check=='read' and principal.subject!=resource.owner:
|
|
270
|
+
csr=await session.csr(resource.id)
|
|
271
|
+
require(csr.requested_issuer==principal.subject and await self.has(principal,'cert.issue',operation,resource.id,session),
|
|
272
|
+
'permission_denied')
|
|
273
|
+
continue
|
|
274
|
+
if check.check=='certgate':
|
|
275
|
+
allowed=await self.has(principal,'resource.certified_write',operation,resource.id,session)
|
|
276
|
+
elif check.check in {'chmod','chgrp'}:
|
|
277
|
+
allowed=principal.subject==resource.owner
|
|
278
|
+
elif check.check=='manage':
|
|
279
|
+
allowed=principal.subject==resource.owner
|
|
280
|
+
elif check.check=='create':
|
|
281
|
+
allowed=allows(resource,principal.subject,memberships,'write') and allows(resource,principal.subject,memberships,'traverse')
|
|
282
|
+
elif check.check=='remove':
|
|
283
|
+
allowed=allows(resource,principal.subject,memberships,'write') and allows(resource,principal.subject,memberships,'traverse')
|
|
284
|
+
elif check.check in {'chown','purge','tool_use'}:
|
|
285
|
+
allowed=False
|
|
286
|
+
else:
|
|
287
|
+
allowed=allows(resource,principal.subject,memberships,check.check)
|
|
288
|
+
if not allowed and check.check=='read':
|
|
289
|
+
allowed=shared_read
|
|
290
|
+
ordinary=await self.ordinary(principal,operation,resource.id,session)
|
|
291
|
+
if not ordinary and check.check=='read':
|
|
292
|
+
output=session.setting('tool_output:'+resource.id)
|
|
293
|
+
ordinary=bool(output and output['subject']==principal.subject and
|
|
294
|
+
await self.has(principal,'tool.use',operation,output['tool_id'],session))
|
|
295
|
+
allowed=allowed and ordinary
|
|
296
|
+
capability=_OVERRIDE.get(check.check)
|
|
297
|
+
if not allowed and capability is not None:
|
|
298
|
+
allowed=await self.has(principal,capability,operation,resource.id,session)
|
|
299
|
+
require(allowed,'permission_denied')
|
|
300
|
+
return tuple(ResourceRef(id=c) for c in principal.certificates)
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""Root-signed attestation that listed backup sets no longer hold one old key.
|
|
2
|
+
|
|
3
|
+
Only the physical-console import in msg.admin.backup_retirement persists a
|
|
4
|
+
record, under a settings key that no network operation writes. Every read
|
|
5
|
+
re-verifies the signature against the current, unrevoked root credential and the
|
|
6
|
+
exact subject and old key IDs; an absent, altered, expired or superseded-root
|
|
7
|
+
record is not evidence. The statement is an operator attestation for the listed
|
|
8
|
+
backup sets only, not a proof that no other copy of the key exists anywhere.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from datetime import datetime, timedelta
|
|
14
|
+
|
|
15
|
+
from msg.constants import ROOT_SUBJECT
|
|
16
|
+
from msg.core.codec import canonical, decode, digest, parse_time, wire
|
|
17
|
+
from msg.core.errors import Failure, require
|
|
18
|
+
from msg.core.models import Signature
|
|
19
|
+
from msg.security.crypto import key_id, verify
|
|
20
|
+
|
|
21
|
+
DOMAIN = 'msg-custodial-backup-retirement-v1'
|
|
22
|
+
PURPOSE = 'custodial-backup-retirement-v1'
|
|
23
|
+
PREFIX = 'custodial_backup_retirement:'
|
|
24
|
+
MAX_VALIDITY = timedelta(days=400)
|
|
25
|
+
FIELDS = frozenset({'domain', 'subject_id', 'old_identity_key_id', 'old_encryption_key_id',
|
|
26
|
+
'backup_sets', 'attested_at', 'not_after', 'operator'})
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True)
|
|
30
|
+
class BackupRetirement:
|
|
31
|
+
"""Constructed only by check(); a bare boolean or dict is never accepted."""
|
|
32
|
+
subject_id: str
|
|
33
|
+
old_identity_key_id: str
|
|
34
|
+
old_encryption_key_id: str
|
|
35
|
+
backup_sets: tuple
|
|
36
|
+
attested_at: datetime
|
|
37
|
+
not_after: datetime
|
|
38
|
+
signer_key_id: str
|
|
39
|
+
record_digest: str
|
|
40
|
+
|
|
41
|
+
def binds(self, subject_id, details):
|
|
42
|
+
return (self.subject_id == subject_id and
|
|
43
|
+
self.old_identity_key_id == details.get('old_identity_key_id') and
|
|
44
|
+
self.old_encryption_key_id == details.get('old_encryption_key_id'))
|
|
45
|
+
|
|
46
|
+
def view(self):
|
|
47
|
+
return {'scope': 'listed_backup_sets_only', 'backup_sets': list(self.backup_sets),
|
|
48
|
+
'attested_at': wire(self.attested_at), 'not_after': wire(self.not_after),
|
|
49
|
+
'signer_key_id': self.signer_key_id, 'record_digest': self.record_digest}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def setting_key(subject_id, old_identity_key_id):
|
|
53
|
+
return PREFIX + subject_id + ':' + old_identity_key_id
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def statement(subject_id, details, backup_sets, *, attested_at, not_after, operator):
|
|
57
|
+
return {'domain': DOMAIN, 'subject_id': subject_id,
|
|
58
|
+
'old_identity_key_id': details['old_identity_key_id'],
|
|
59
|
+
'old_encryption_key_id': details['old_encryption_key_id'],
|
|
60
|
+
'backup_sets': sorted(set(backup_sets)), 'attested_at': wire(attested_at),
|
|
61
|
+
'not_after': wire(not_after), 'operator': operator}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def sign_record(body, signer):
|
|
65
|
+
return {'statement': body, 'signature': wire(signer.sign(canonical(body), purpose=PURPOSE))}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
async def root_verifier(tx):
|
|
69
|
+
"""The key of the active, unrevoked root certificate; rotation retires old records."""
|
|
70
|
+
try:
|
|
71
|
+
certificate_id = tx.setting('active_root_certificate')
|
|
72
|
+
certificate = await tx.certificate(certificate_id)
|
|
73
|
+
credential = await tx.credential(certificate.key_id)
|
|
74
|
+
require(certificate.subject_id == ROOT_SUBJECT and
|
|
75
|
+
not await tx.certificate_revoked(certificate_id) and
|
|
76
|
+
credential.subject_id == ROOT_SUBJECT and credential.kind == 'signing_key' and
|
|
77
|
+
credential.revoked_at is None and
|
|
78
|
+
key_id(credential.verifier) == credential.id == certificate.key_id,
|
|
79
|
+
'backup_retirement_root_unavailable')
|
|
80
|
+
except (Failure, KeyError, TypeError, ValueError) as exc:
|
|
81
|
+
raise Failure('backup_retirement_root_unavailable') from exc
|
|
82
|
+
return credential.verifier
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _sets(value):
|
|
86
|
+
return (isinstance(value, list) and bool(value) and value == sorted(set(value)) and
|
|
87
|
+
all(isinstance(item, str) and 0 < len(item) <= 256 for item in value))
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def check(record, verifier, subject_id, details, *, now):
|
|
91
|
+
"""Return the attestation only if this exact record is valid now for these old keys."""
|
|
92
|
+
try:
|
|
93
|
+
require(isinstance(record, dict) and set(record) == {'statement', 'signature'},
|
|
94
|
+
'backup_retirement_invalid')
|
|
95
|
+
body = record['statement']
|
|
96
|
+
require(isinstance(body, dict) and set(body) == FIELDS and body['domain'] == DOMAIN and
|
|
97
|
+
all(isinstance(body[name], str) and body[name] for name in
|
|
98
|
+
('subject_id', 'old_identity_key_id', 'old_encryption_key_id', 'operator')) and
|
|
99
|
+
_sets(body['backup_sets']), 'backup_retirement_invalid')
|
|
100
|
+
attested, not_after = parse_time(body['attested_at']), parse_time(body['not_after'])
|
|
101
|
+
signature = decode(Signature, record['signature'])
|
|
102
|
+
verify(verifier, canonical(body), signature, purpose=PURPOSE)
|
|
103
|
+
require(attested < not_after <= attested + MAX_VALIDITY, 'backup_retirement_invalid')
|
|
104
|
+
except (Failure, KeyError, TypeError, ValueError) as exc:
|
|
105
|
+
raise Failure('backup_retirement_invalid') from exc
|
|
106
|
+
require(isinstance(details, dict) and body['subject_id'] == subject_id and
|
|
107
|
+
body['old_identity_key_id'] == details.get('old_identity_key_id') and
|
|
108
|
+
body['old_encryption_key_id'] == details.get('old_encryption_key_id'),
|
|
109
|
+
'backup_retirement_binding_mismatch')
|
|
110
|
+
try:
|
|
111
|
+
current = attested <= now < not_after
|
|
112
|
+
except TypeError:
|
|
113
|
+
current = False
|
|
114
|
+
require(current, 'backup_retirement_expired')
|
|
115
|
+
return BackupRetirement(subject_id=subject_id,
|
|
116
|
+
old_identity_key_id=body['old_identity_key_id'],
|
|
117
|
+
old_encryption_key_id=body['old_encryption_key_id'],
|
|
118
|
+
backup_sets=tuple(body['backup_sets']), attested_at=attested, not_after=not_after,
|
|
119
|
+
signer_key_id=signature.key_id, record_digest=digest(record))
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
async def verified(tx, subject_id, details, *, now):
|
|
123
|
+
"""(status, attestation-or-None); any failure is reported as not retired."""
|
|
124
|
+
old = details.get('old_identity_key_id') if isinstance(details, dict) else None
|
|
125
|
+
if not isinstance(subject_id, str) or not isinstance(old, str) or not old:
|
|
126
|
+
return 'unavailable', None
|
|
127
|
+
record = tx.setting(setting_key(subject_id, old))
|
|
128
|
+
if record is None:
|
|
129
|
+
return 'unavailable', None
|
|
130
|
+
if now is None:
|
|
131
|
+
return 'clock_unavailable', None
|
|
132
|
+
try:
|
|
133
|
+
return 'verified', check(record, await root_verifier(tx), subject_id, details, now=now)
|
|
134
|
+
except Failure as exc:
|
|
135
|
+
return ('expired' if exc.code == 'backup_retirement_expired' else 'invalid'), None
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""Finite, versioned authority vocabulary. A ceiling is a limit, not a grant."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from dataclasses import replace
|
|
5
|
+
from msg.constants import ROOT_SPACE
|
|
6
|
+
from msg.core.models import CapabilitySpec, CapabilityGrant, Scope, ResourceRef
|
|
7
|
+
from msg.plugins.schemas import NETWORK_CONSTRAINTS
|
|
8
|
+
|
|
9
|
+
# Base capabilities route ordinary actions to mode/ownership checks. They never
|
|
10
|
+
# stand in for a special capability or a trusted local execution entry.
|
|
11
|
+
BASE_FAMILIES = {
|
|
12
|
+
'identity.basic': ('identity.register', 'identity.temporary', 'identity.custodial_create', 'identity.upgrade',
|
|
13
|
+
'identity.token_rotate', 'identity.token_create', 'identity.token_recover',
|
|
14
|
+
'identity.upgrade_result',
|
|
15
|
+
'identity.key_add', 'identity.key_revoke',
|
|
16
|
+
'identity.email_set', 'identity.email_verify', 'identity.email_get', 'identity.email_notifications',
|
|
17
|
+
'identity.delegate', 'identity.delegation_revoke', 'identity.certificate_renew',
|
|
18
|
+
'identity.ssh_key_add', 'identity.ssh_key_revoke', 'identity.ssh_certificates',
|
|
19
|
+
'identity.identity_key_get', 'identity.identity_key_list',
|
|
20
|
+
'identity.encryption_key_get', 'identity.encryption_key_list',
|
|
21
|
+
'identity.encryption_key_rotate',
|
|
22
|
+
'identity.custodial_status','identity.custodial_upgrade_start',
|
|
23
|
+
'identity.custodial_upgrade_finish','identity.personal_put','identity.soul_visibility','identity.note_put',
|
|
24
|
+
'identity.custodial_rewrap_entry','identity.custodial_rewrap_revision',
|
|
25
|
+
'identity.custodial_rewrap_ack','identity.custodial_migration_get',
|
|
26
|
+
'identity.custodial_upgrade_result','identity.custodial_upgrade_inventory',
|
|
27
|
+
'identity.note_list','identity.note_get',
|
|
28
|
+
'identity.note_archive','identity.note_restore','identity.todo_put',
|
|
29
|
+
'identity.todo_list','identity.todo_get','identity.todo_archive','identity.todo_restore',
|
|
30
|
+
'identity.legacy_put','identity.legacy_archive','identity.legacy_get','identity.legacy_status',
|
|
31
|
+
'identity.recovery_policy_get','identity.recovery_policy_set',
|
|
32
|
+
'identity.recovery_envelope_register','identity.recovery_envelope_get',
|
|
33
|
+
'identity.recovery_envelope_list','identity.recovery_custodians',
|
|
34
|
+
'achievement.start', 'achievement.answer', 'achievement.finish',
|
|
35
|
+
'achievement.pin','achievement.unpin','achievement.reorder'),
|
|
36
|
+
'resource.basic': ('content.',),
|
|
37
|
+
'sharing.basic': ('sharing.',),
|
|
38
|
+
'discussion.basic': ('discussion.',),
|
|
39
|
+
'communication.basic': ('communication.',),
|
|
40
|
+
'money.basic': ('money.',),
|
|
41
|
+
'store.basic': ('store.',),
|
|
42
|
+
'bounty.basic': ('bounty.',),
|
|
43
|
+
'orders.basic': ('orders.',),
|
|
44
|
+
'delivery.basic': ('delivery.',),
|
|
45
|
+
'discovery.basic': ('discovery.', 'cert.get', 'job.get', 'achievement.list'),
|
|
46
|
+
'transfer.basic': ('transfer.',),
|
|
47
|
+
'group.basic': ('group.',),
|
|
48
|
+
'cert.request': ('cert.request', 'cert.cancel'),
|
|
49
|
+
'git.basic': ('git.',),
|
|
50
|
+
'hosting.basic': ('hosting.',),
|
|
51
|
+
'keystore.basic': ('keystore.',),
|
|
52
|
+
'batch.basic': ('batch.',),
|
|
53
|
+
}
|
|
54
|
+
EXCLUDED_BASE = {'content.purge','content.chown','identity.recover'}
|
|
55
|
+
TEMPORARY_OPERATIONS = frozenset({
|
|
56
|
+
'identity.temporary','identity.custodial_create','identity.upgrade','identity.token_rotate','identity.custodial_status',
|
|
57
|
+
'identity.custodial_upgrade_start','identity.custodial_upgrade_finish',
|
|
58
|
+
'identity.custodial_rewrap_entry','identity.custodial_rewrap_revision',
|
|
59
|
+
'identity.custodial_migration_get','identity.custodial_upgrade_inventory',
|
|
60
|
+
'identity.custodial_rewrap_ack',
|
|
61
|
+
'content.topic_create','content.post_create','content.post_edit','content.archive',
|
|
62
|
+
'content.restore','content.file_put','content.attach',
|
|
63
|
+
'discussion.reply','discussion.quote','discussion.repost','discussion.like','discussion.unlike',
|
|
64
|
+
'discussion.ack','discussion.acks','discussion.thread',
|
|
65
|
+
'communication.send','communication.watch','communication.unwatch',
|
|
66
|
+
'communication.inbox','communication.outbox','communication.changes',
|
|
67
|
+
'communication.handoff_create','communication.handoff_get','communication.handoff_list',
|
|
68
|
+
'communication.handoff_decide','communication.lease_acquire','communication.lease_get',
|
|
69
|
+
'communication.lease_list','communication.lease_renew','communication.lease_release',
|
|
70
|
+
'discovery.get','discovery.raw','discovery.list','discovery.search','discovery.operations',
|
|
71
|
+
'discovery.capabilities','discovery.schema','discovery.diff','discovery.references',
|
|
72
|
+
'transfer.open','transfer.part_put','transfer.part_get','transfer.status','transfer.seal','transfer.cancel',
|
|
73
|
+
'keystore.get',
|
|
74
|
+
'batch.independent','batch.atomic','job.get',
|
|
75
|
+
'achievement.start','achievement.answer','achievement.finish','achievement.list',
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _selected(operations, selectors):
|
|
80
|
+
return frozenset(f'{op.name}@{op.version}' for op in operations
|
|
81
|
+
if any(op.name.startswith(s) if s.endswith('.') else op.name==s for s in selectors))
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def install_capabilities(registry):
|
|
85
|
+
operations=registry.operations()
|
|
86
|
+
types=frozenset(spec.name for spec in registry.resource_types())
|
|
87
|
+
by_name={op.name:op for op in operations}
|
|
88
|
+
network_constraints=ResourceRef(id='schema:network-constraints')
|
|
89
|
+
registry.add_schema(network_constraints, NETWORK_CONSTRAINTS)
|
|
90
|
+
for name,selectors in BASE_FAMILIES.items():
|
|
91
|
+
names=_selected(operations,selectors)-frozenset(f'{n}@1' for n in EXCLUDED_BASE)
|
|
92
|
+
if names:
|
|
93
|
+
registry.add_capability(CapabilitySpec(name=name,version=1,scope_types=types,
|
|
94
|
+
operations=names,replaces_checks=frozenset(),delegatable=True,ca_only=False,constraints_schema=None))
|
|
95
|
+
reads=frozenset(f'{op.name}@{op.version}' for op in operations if op.effect=='read')
|
|
96
|
+
writes=frozenset(f'{op.name}@{op.version}' for op in operations if op.effect!='read')
|
|
97
|
+
all_ops=reads|writes
|
|
98
|
+
# An override only replaces the named check; it cannot waive a credential
|
|
99
|
+
# ceiling, certgate, tool gate, sticky, or root.local_only.
|
|
100
|
+
rules={
|
|
101
|
+
'resource.certified_write': (writes,()),
|
|
102
|
+
'resource.read_override': (all_ops,('read','list','traverse')),
|
|
103
|
+
'resource.write_override': (writes,('write',)),
|
|
104
|
+
'resource.chmod_override': (_selected(operations,('content.chmod',)),('chmod',)),
|
|
105
|
+
'resource.chgrp_override': (_selected(operations,('content.chgrp',)),('chgrp',)),
|
|
106
|
+
'resource.chown': (_selected(operations,('content.chown',)),('chown',)),
|
|
107
|
+
'resource.purge': (_selected(operations,('content.purge',)),('purge',)),
|
|
108
|
+
'identity.recover': (_selected(operations,('identity.recover',)),()),
|
|
109
|
+
'group.manage_override': (_selected(operations,('group.member.','group.admin.',
|
|
110
|
+
'group.invite','group.approve','group.reject','group.remove')),('manage',)),
|
|
111
|
+
'tool.use': (_selected(operations,('tool.run','discovery.get','discovery.list','transfer.open',
|
|
112
|
+
'transfer.part_get','transfer.status','transfer.seal','transfer.cancel','discovery.raw','job.get')),('tool_use',)),
|
|
113
|
+
'tool.net.private': (_selected(operations,('tool.run',)),()),
|
|
114
|
+
'webhook.domain': (_selected(operations,('communication.webhook_subscribe',)),()),
|
|
115
|
+
'system.namespace': (all_ops,()),
|
|
116
|
+
'cert.issue': (_selected(operations,('cert.publish','cert.get','discovery.get')),()),
|
|
117
|
+
'cert.revoke': (_selected(operations,('cert.revoke',)),()),
|
|
118
|
+
'cert.ca.issue': (_selected(operations,('cert.publish',)),()),
|
|
119
|
+
'system.inspect': (_selected(operations,('system.inspect',)),()),
|
|
120
|
+
'system.config': (_selected(operations,('system.config','system.share_links_set')),()),
|
|
121
|
+
'system.maintenance': (_selected(operations,('system.maintenance',)),()),
|
|
122
|
+
}
|
|
123
|
+
for name,(names,replaces) in rules.items():
|
|
124
|
+
registry.add_capability(CapabilitySpec(name=name,version=1,scope_types=types,operations=names,
|
|
125
|
+
replaces_checks=frozenset(replaces),delegatable=name not in {'cert.ca.issue'},
|
|
126
|
+
ca_only=name.startswith('cert.'),constraints_schema=network_constraints if name.startswith('tool.') else None))
|
|
127
|
+
# Publish a finite supported operation vocabulary on every resource type.
|
|
128
|
+
# Handlers still check the precise role of each resource argument (source,
|
|
129
|
+
# destination, owner, etc.), rather than inferring it from this directory.
|
|
130
|
+
for key,spec in tuple(registry._types.items()):
|
|
131
|
+
relations=frozenset({'reply_to','thread_root','quote','repost','attachment','template'}) if spec.name=='post' else frozenset()
|
|
132
|
+
registry._types[key]=replace(spec,operations=all_ops,relations=relations)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def grant_for(spec, *, scope=None, operations=None):
|
|
136
|
+
return CapabilityGrant(capability=spec.name,version=spec.version,
|
|
137
|
+
scope=scope or Scope(resource_id=ROOT_SPACE,descendants=True),
|
|
138
|
+
operations=spec.operations if operations is None else frozenset(operations),constraints={})
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def primary_ceiling(registry,scope=None):
|
|
142
|
+
# Explicit snapshot: future registry entries do not enter existing credentials.
|
|
143
|
+
return tuple(grant_for(spec,scope=scope) for spec in registry.capabilities() if spec.operations)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def base_grants(registry,scope=None):
|
|
147
|
+
return tuple(grant_for(spec,scope=scope) for spec in registry.capabilities() if spec.name in BASE_FAMILIES and spec.operations)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def temporary_ceiling(registry,scope=None):
|
|
151
|
+
temporary=frozenset(f'{name}@1' for name in TEMPORARY_OPERATIONS)|{
|
|
152
|
+
'identity.upgrade@2','identity.temporary@2','identity.temporary@3',
|
|
153
|
+
'identity.custodial_create@2',
|
|
154
|
+
'identity.custodial_upgrade_finish@2','identity.custodial_rewrap_ack@2',
|
|
155
|
+
'identity.token_rotate@2'}
|
|
156
|
+
return tuple(grant_for(spec,scope=scope,operations=spec.operations&temporary) for spec in registry.capabilities()
|
|
157
|
+
if spec.name in BASE_FAMILIES and spec.operations&temporary)
|