intermesh 0.3.0__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.
- intermesh/__init__.py +56 -0
- intermesh/adapters.py +90 -0
- intermesh/admin.py +733 -0
- intermesh/agent.py +520 -0
- intermesh/apikeys.py +273 -0
- intermesh/audit.py +153 -0
- intermesh/bridge.py +181 -0
- intermesh/cli.py +453 -0
- intermesh/config.py +145 -0
- intermesh/crypto.py +150 -0
- intermesh/egress.py +180 -0
- intermesh/escrow.py +208 -0
- intermesh/guardrails.py +235 -0
- intermesh/hardware.py +32 -0
- intermesh/health.py +56 -0
- intermesh/hub.py +996 -0
- intermesh/identity.py +94 -0
- intermesh/logger.py +90 -0
- intermesh/message.py +152 -0
- intermesh/metrics.py +49 -0
- intermesh/peering.py +103 -0
- intermesh/pipeline.py +218 -0
- intermesh/policy.py +35 -0
- intermesh/ratelimit.py +42 -0
- intermesh/schema.py +125 -0
- intermesh/secret.py +115 -0
- intermesh/signing.py +64 -0
- intermesh/snapshot.py +342 -0
- intermesh/store.py +177 -0
- intermesh/task.py +122 -0
- intermesh-0.3.0.dist-info/METADATA +198 -0
- intermesh-0.3.0.dist-info/RECORD +35 -0
- intermesh-0.3.0.dist-info/WHEEL +5 -0
- intermesh-0.3.0.dist-info/entry_points.txt +2 -0
- intermesh-0.3.0.dist-info/top_level.txt +1 -0
intermesh/admin.py
ADDED
|
@@ -0,0 +1,733 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Moteur de commandes de la console d'administration.
|
|
3
|
+
|
|
4
|
+
Séparé du Hub pour rester testable sans serveur en marche, et pour que
|
|
5
|
+
la surface d'administration soit lisible d'un seul tenant — c'est elle
|
|
6
|
+
qui concentre le risque.
|
|
7
|
+
|
|
8
|
+
MODÈLE D'AUTORISATION
|
|
9
|
+
---------------------
|
|
10
|
+
À l'enregistrement, un agent sans clé d'API choisit lui-même ses rôles :
|
|
11
|
+
|
|
12
|
+
roles = d.get("roles", ["standard"])
|
|
13
|
+
|
|
14
|
+
C'est acceptable pour un maillage où les agents s'échangent des messages,
|
|
15
|
+
et inacceptable pour une console capable de révoquer des clés ou de
|
|
16
|
+
déconnecter des agents. Toute commande d'administration exige donc :
|
|
17
|
+
|
|
18
|
+
1. une identité authentifiée par CLÉ D'API — jamais des rôles déclarés
|
|
19
|
+
par le client. La preuve est portée par le JWT signé par le Hub,
|
|
20
|
+
donc infalsifiable ;
|
|
21
|
+
2. le rôle `admin` ou `org_admin`.
|
|
22
|
+
|
|
23
|
+
Les deux conditions sont vérifiées ici, à un seul endroit.
|
|
24
|
+
|
|
25
|
+
CLOISONNEMENT PAR ORGANISATION
|
|
26
|
+
-------------------------------
|
|
27
|
+
`admin` voit et administre tout le Hub, organisations confondues : c'est
|
|
28
|
+
le rôle de l'opérateur du Hub lui-même, inchangé depuis la première
|
|
29
|
+
version de cette console.
|
|
30
|
+
|
|
31
|
+
`org_admin` est plus étroit : il ne voit et n'agit que sur l'organisation
|
|
32
|
+
de sa propre clé d'API (`org_id`, porté par le JWT, donc lui aussi
|
|
33
|
+
infalsifiable). C'est le rôle à distribuer à chaque entreprise qui
|
|
34
|
+
partage un Hub avec d'autres — indispensable dès qu'un portail web laisse
|
|
35
|
+
plusieurs organisations générer leurs propres clés sur la même instance :
|
|
36
|
+
sans ce cloisonnement, la clé admin d'une entreprise verrait les agents,
|
|
37
|
+
tâches et journaux d'audit de toutes les autres.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
from __future__ import annotations
|
|
41
|
+
|
|
42
|
+
import dataclasses
|
|
43
|
+
import time
|
|
44
|
+
from typing import Any, Callable, Optional
|
|
45
|
+
|
|
46
|
+
from intermesh import snapshot as snapshot_store
|
|
47
|
+
from intermesh.escrow import EscrowError
|
|
48
|
+
from intermesh.guardrails import GuardrailPolicy
|
|
49
|
+
from intermesh.snapshot import SnapshotError
|
|
50
|
+
from intermesh.task import InterMeshTask, TaskStatus
|
|
51
|
+
|
|
52
|
+
# Commandes qui modifient l'état. Isolées pour être journalisées
|
|
53
|
+
# distinctement des simples lectures.
|
|
54
|
+
MUTATING = {
|
|
55
|
+
"agent.disconnect",
|
|
56
|
+
"task.cancel",
|
|
57
|
+
"task.retry",
|
|
58
|
+
"apikey.create",
|
|
59
|
+
"apikey.revoke",
|
|
60
|
+
"guardrails.set_policy",
|
|
61
|
+
"escrow.grant",
|
|
62
|
+
"escrow.release",
|
|
63
|
+
"escrow.refund",
|
|
64
|
+
"snapshot.create",
|
|
65
|
+
"snapshot.restore",
|
|
66
|
+
"snapshot.delete",
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class AdminError(Exception):
|
|
71
|
+
"""Commande refusée ou impossible à exécuter."""
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class AdminContext:
|
|
75
|
+
"""Vue sur l'état du Hub fournie au moteur de commandes."""
|
|
76
|
+
|
|
77
|
+
def __init__(
|
|
78
|
+
self,
|
|
79
|
+
*,
|
|
80
|
+
agents: dict,
|
|
81
|
+
identity_registry: dict,
|
|
82
|
+
task_registry: dict,
|
|
83
|
+
audit_log,
|
|
84
|
+
api_keys,
|
|
85
|
+
peered_hubs: dict,
|
|
86
|
+
store,
|
|
87
|
+
my_org: str,
|
|
88
|
+
remember_task: Callable,
|
|
89
|
+
send_to_agent: Callable,
|
|
90
|
+
hub_version: str = "1.5",
|
|
91
|
+
caller_org: str = "default",
|
|
92
|
+
scoped: bool = False,
|
|
93
|
+
asimov_engine: Any = None,
|
|
94
|
+
escrow_manager: Any = None,
|
|
95
|
+
snapshot_dir: Any = None,
|
|
96
|
+
):
|
|
97
|
+
self.snapshot_dir = snapshot_dir
|
|
98
|
+
self.agents = agents
|
|
99
|
+
self.identity_registry = identity_registry
|
|
100
|
+
self.task_registry = task_registry
|
|
101
|
+
self.audit_log = audit_log
|
|
102
|
+
self.api_keys = api_keys
|
|
103
|
+
self.peered_hubs = peered_hubs
|
|
104
|
+
self.store = store
|
|
105
|
+
self.asimov_engine = asimov_engine
|
|
106
|
+
self.escrow_manager = escrow_manager
|
|
107
|
+
self.my_org = my_org
|
|
108
|
+
self.remember_task = remember_task
|
|
109
|
+
self.send_to_agent = send_to_agent
|
|
110
|
+
self.hub_version = hub_version
|
|
111
|
+
# Organisation du porteur du token, et `True` si son rôle est
|
|
112
|
+
# `org_admin` — auquel cas chaque commande doit filtrer sa vue et
|
|
113
|
+
# ses actions à cette seule organisation. `admin` laisse `scoped`
|
|
114
|
+
# à `False` : comportement hub-wide inchangé.
|
|
115
|
+
self.caller_org = caller_org
|
|
116
|
+
self.scoped = scoped
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def authorize(token_payload: dict) -> None:
|
|
120
|
+
"""
|
|
121
|
+
Vérifie qu'un porteur de token a le droit d'administrer.
|
|
122
|
+
|
|
123
|
+
Raises:
|
|
124
|
+
AdminError: si l'identité n'est pas issue d'une clé d'API, ou si
|
|
125
|
+
ni le rôle `admin` ni `org_admin` ne sont présents.
|
|
126
|
+
"""
|
|
127
|
+
if token_payload.get("auth_method") != "api_key":
|
|
128
|
+
raise AdminError(
|
|
129
|
+
"ADMIN_DENIED: l'administration exige une identité authentifiée "
|
|
130
|
+
"par clé d'API. Les rôles déclarés à l'enregistrement ne suffisent pas."
|
|
131
|
+
)
|
|
132
|
+
roles = token_payload.get("roles", [])
|
|
133
|
+
if "admin" not in roles and "org_admin" not in roles:
|
|
134
|
+
raise AdminError("ADMIN_DENIED: rôle 'admin' ou 'org_admin' requis.")
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def caller_scope(token_payload: dict) -> tuple[str, bool]:
|
|
138
|
+
"""
|
|
139
|
+
(organisation du porteur du token, `True` s'il doit être cloisonné).
|
|
140
|
+
|
|
141
|
+
`admin` l'emporte sur `org_admin` si un token portait les deux : un
|
|
142
|
+
opérateur de Hub qui s'octroie aussi `org_admin` reste hub-wide.
|
|
143
|
+
"""
|
|
144
|
+
roles = token_payload.get("roles", [])
|
|
145
|
+
scoped = "admin" not in roles and "org_admin" in roles
|
|
146
|
+
return token_payload.get("org_id", "default"), scoped
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _org_of(qualified_name: Optional[str]) -> str:
|
|
150
|
+
"""
|
|
151
|
+
Organisation porteuse d'un nom qualifié InterMesh (`"acme/bot"` -> `"acme"`).
|
|
152
|
+
|
|
153
|
+
Un nom sans préfixe appartient à l'organisation par défaut du Hub —
|
|
154
|
+
la même convention que `AgentIdentity.qualified_name`.
|
|
155
|
+
"""
|
|
156
|
+
if not qualified_name:
|
|
157
|
+
return "default"
|
|
158
|
+
return qualified_name.split("/")[0] if "/" in qualified_name else "default"
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
# Évènements qui décrivent le Hub lui-même ou sa fédération, pas une
|
|
162
|
+
# organisation en particulier : un `org_admin` ne doit jamais les voir,
|
|
163
|
+
# même quand leur `sender`/`target` ressemble par accident à son org_id.
|
|
164
|
+
_HUB_LEVEL_EVENTS = {"GENESIS", "PEERING_ESTABLISHED", "PEERING_ACCEPTED"}
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
# ----------------------------------------------------------------------
|
|
168
|
+
# Commandes
|
|
169
|
+
# ----------------------------------------------------------------------
|
|
170
|
+
|
|
171
|
+
def _hub_info(ctx: AdminContext, params: dict) -> dict:
|
|
172
|
+
if ctx.scoped:
|
|
173
|
+
identities = [i for i in ctx.identity_registry.values() if i.org_id == ctx.caller_org]
|
|
174
|
+
tasks = [t for t in ctx.task_registry.values()
|
|
175
|
+
if ctx.caller_org in (_org_of(t.orchestrator), _org_of(t.assignee))]
|
|
176
|
+
audit_entries = sum(
|
|
177
|
+
1 for e in ctx.audit_log.chain
|
|
178
|
+
if e.event_type not in _HUB_LEVEL_EVENTS
|
|
179
|
+
and ctx.caller_org in (_org_of(e.sender), _org_of(e.target))
|
|
180
|
+
)
|
|
181
|
+
api_keys_count = sum(1 for k in ctx.api_keys.describe() if k["org_id"] == ctx.caller_org)
|
|
182
|
+
federation_peers: list[str] = [] # information hub-wide, réservée à `admin`
|
|
183
|
+
else:
|
|
184
|
+
identities = list(ctx.identity_registry.values())
|
|
185
|
+
tasks = list(ctx.task_registry.values())
|
|
186
|
+
audit_entries = len(ctx.audit_log.chain)
|
|
187
|
+
api_keys_count = len(ctx.api_keys)
|
|
188
|
+
federation_peers = sorted(ctx.peered_hubs.keys())
|
|
189
|
+
|
|
190
|
+
by_status: dict[str, int] = {}
|
|
191
|
+
for task in tasks:
|
|
192
|
+
by_status[task.status.value] = by_status.get(task.status.value, 0) + 1
|
|
193
|
+
agents_online = sum(1 for i in identities if i.qualified_name in ctx.agents)
|
|
194
|
+
|
|
195
|
+
return {
|
|
196
|
+
"org": ctx.caller_org if ctx.scoped else ctx.my_org,
|
|
197
|
+
"version": ctx.hub_version,
|
|
198
|
+
"agents_online": agents_online,
|
|
199
|
+
"agents_known": len(identities),
|
|
200
|
+
"tasks_total": len(tasks),
|
|
201
|
+
"tasks_by_status": by_status,
|
|
202
|
+
"audit_entries": audit_entries,
|
|
203
|
+
"audit_intact": ctx.audit_log.verify_integrity(),
|
|
204
|
+
"federation_peers": federation_peers,
|
|
205
|
+
"state_backend": getattr(ctx.store, "description", "inconnu"),
|
|
206
|
+
"state_ephemeral": getattr(ctx.store, "ephemeral", True),
|
|
207
|
+
"api_keys": api_keys_count,
|
|
208
|
+
"api_keys_source": ctx.api_keys.source,
|
|
209
|
+
"api_keys_mutable": ctx.api_keys.mutable,
|
|
210
|
+
"server_time": time.time(),
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _agents_list(ctx: AdminContext, params: dict) -> dict:
|
|
215
|
+
"""
|
|
216
|
+
Inventaire complet : les agents connectés, mais aussi ceux connus et
|
|
217
|
+
actuellement hors ligne — invisibles jusqu'ici faute de persistance.
|
|
218
|
+
"""
|
|
219
|
+
out = []
|
|
220
|
+
for name, identity in sorted(ctx.identity_registry.items()):
|
|
221
|
+
if ctx.scoped and identity.org_id != ctx.caller_org:
|
|
222
|
+
continue
|
|
223
|
+
pending = sum(
|
|
224
|
+
1 for t in ctx.task_registry.values()
|
|
225
|
+
if t.assignee == name and t.status in (TaskStatus.PENDING, TaskStatus.RUNNING)
|
|
226
|
+
)
|
|
227
|
+
out.append({
|
|
228
|
+
"name": name,
|
|
229
|
+
"agent_id": identity.agent_id,
|
|
230
|
+
"org_id": identity.org_id,
|
|
231
|
+
"capabilities": identity.capabilities,
|
|
232
|
+
"roles": identity.roles,
|
|
233
|
+
"permissions": identity.permissions,
|
|
234
|
+
"metadata": identity.metadata,
|
|
235
|
+
"fingerprint": identity.fingerprint,
|
|
236
|
+
"encrypted": bool(identity.public_key),
|
|
237
|
+
"online": name in ctx.agents,
|
|
238
|
+
"pending_tasks": pending,
|
|
239
|
+
})
|
|
240
|
+
online = sum(1 for a in out if a["online"])
|
|
241
|
+
return {"agents": out, "online": online, "known": len(out)}
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
async def _agent_disconnect(ctx: AdminContext, params: dict) -> dict:
|
|
245
|
+
name = params.get("name")
|
|
246
|
+
if ctx.scoped:
|
|
247
|
+
identity = ctx.identity_registry.get(name)
|
|
248
|
+
if identity is None or identity.org_id != ctx.caller_org:
|
|
249
|
+
raise AdminError(f"'{name}' est hors de votre organisation.")
|
|
250
|
+
ws = ctx.agents.get(name)
|
|
251
|
+
if ws is None:
|
|
252
|
+
raise AdminError(f"'{name}' n'est pas connecté.")
|
|
253
|
+
await ws.close()
|
|
254
|
+
return {"disconnected": name}
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _tasks_list(ctx: AdminContext, params: dict) -> dict:
|
|
258
|
+
status = params.get("status")
|
|
259
|
+
assignee = params.get("assignee")
|
|
260
|
+
limit = min(int(params.get("limit", 100)), 500)
|
|
261
|
+
|
|
262
|
+
tasks = list(ctx.task_registry.values())
|
|
263
|
+
if ctx.scoped:
|
|
264
|
+
tasks = [t for t in tasks
|
|
265
|
+
if ctx.caller_org in (_org_of(t.orchestrator), _org_of(t.assignee))]
|
|
266
|
+
if status:
|
|
267
|
+
tasks = [t for t in tasks if t.status.value == status]
|
|
268
|
+
if assignee:
|
|
269
|
+
tasks = [t for t in tasks if t.assignee == assignee]
|
|
270
|
+
tasks.sort(key=lambda t: t.updated_at, reverse=True)
|
|
271
|
+
|
|
272
|
+
return {
|
|
273
|
+
"tasks": [
|
|
274
|
+
{
|
|
275
|
+
"task_id": t.task_id,
|
|
276
|
+
"title": t.title,
|
|
277
|
+
"orchestrator": t.orchestrator,
|
|
278
|
+
"assignee": t.assignee,
|
|
279
|
+
"status": t.status.value,
|
|
280
|
+
"error_message": t.error_message,
|
|
281
|
+
"created_at": t.created_at,
|
|
282
|
+
"updated_at": t.updated_at,
|
|
283
|
+
# input_data et output_data sont chiffrés de bout en bout :
|
|
284
|
+
# le Hub ne peut pas les lire, la console non plus.
|
|
285
|
+
"encrypted_payload": isinstance(t.input_data, str),
|
|
286
|
+
}
|
|
287
|
+
for t in tasks[:limit]
|
|
288
|
+
],
|
|
289
|
+
"total": len(tasks),
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _check_task_in_scope(ctx: AdminContext, task) -> None:
|
|
294
|
+
if ctx.scoped and ctx.caller_org not in (_org_of(task.orchestrator), _org_of(task.assignee)):
|
|
295
|
+
raise AdminError("Cette tâche est hors de votre organisation.")
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _task_cancel(ctx: AdminContext, params: dict) -> dict:
|
|
299
|
+
task = ctx.task_registry.get(params.get("task_id"))
|
|
300
|
+
if task is None:
|
|
301
|
+
raise AdminError("Tâche inconnue.")
|
|
302
|
+
_check_task_in_scope(ctx, task)
|
|
303
|
+
if task.status in (TaskStatus.COMPLETED, TaskStatus.FAILED):
|
|
304
|
+
raise AdminError(f"Tâche déjà terminée ({task.status.value}).")
|
|
305
|
+
|
|
306
|
+
task.update_status(TaskStatus.FAILED, error_message="Annulée par un administrateur.")
|
|
307
|
+
ctx.remember_task(task)
|
|
308
|
+
return {"task_id": task.task_id, "status": task.status.value}
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
async def _task_retry(ctx: AdminContext, params: dict) -> dict:
|
|
312
|
+
task = ctx.task_registry.get(params.get("task_id"))
|
|
313
|
+
if task is None:
|
|
314
|
+
raise AdminError("Tâche inconnue.")
|
|
315
|
+
_check_task_in_scope(ctx, task)
|
|
316
|
+
|
|
317
|
+
task.update_status(TaskStatus.PENDING)
|
|
318
|
+
ctx.remember_task(task)
|
|
319
|
+
|
|
320
|
+
delivered = await ctx.send_to_agent(task)
|
|
321
|
+
return {
|
|
322
|
+
"task_id": task.task_id,
|
|
323
|
+
"status": task.status.value,
|
|
324
|
+
# Si l'exécutant est absent, la tâche reste en attente : elle lui
|
|
325
|
+
# sera repoussée à sa reconnexion par le mécanisme de reprise.
|
|
326
|
+
"delivered": delivered,
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def _audit_list(ctx: AdminContext, params: dict) -> dict:
|
|
331
|
+
limit = min(int(params.get("limit", 100)), 1000)
|
|
332
|
+
offset = int(params.get("offset", 0))
|
|
333
|
+
event_type = params.get("event_type")
|
|
334
|
+
|
|
335
|
+
chain = ctx.audit_log.chain
|
|
336
|
+
entries = [e.to_dict() for e in chain]
|
|
337
|
+
if ctx.scoped:
|
|
338
|
+
entries = [
|
|
339
|
+
e for e in entries
|
|
340
|
+
if e["event_type"] not in _HUB_LEVEL_EVENTS
|
|
341
|
+
and ctx.caller_org in (_org_of(e["sender"]), _org_of(e.get("target")))
|
|
342
|
+
]
|
|
343
|
+
if event_type:
|
|
344
|
+
entries = [e for e in entries if e["event_type"] == event_type]
|
|
345
|
+
entries.reverse()
|
|
346
|
+
|
|
347
|
+
return {
|
|
348
|
+
"entries": entries[offset:offset + limit],
|
|
349
|
+
"total": len(entries),
|
|
350
|
+
"chain_length": len(chain),
|
|
351
|
+
"intact": ctx.audit_log.verify_integrity(),
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def _audit_verify(ctx: AdminContext, params: dict) -> dict:
|
|
356
|
+
"""
|
|
357
|
+
Revérifie la chaîne et localise la première rupture s'il y en a une.
|
|
358
|
+
"""
|
|
359
|
+
chain = ctx.audit_log.chain
|
|
360
|
+
broken_at = None
|
|
361
|
+
for i in range(1, len(chain)):
|
|
362
|
+
if chain[i].prev_hash != chain[i - 1].hash or chain[i].hash != chain[i].compute_hash():
|
|
363
|
+
broken_at = i
|
|
364
|
+
break
|
|
365
|
+
|
|
366
|
+
return {
|
|
367
|
+
"intact": broken_at is None,
|
|
368
|
+
"chain_length": len(chain),
|
|
369
|
+
"broken_at_index": broken_at,
|
|
370
|
+
"verified_at": time.time(),
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def _apikeys_list(ctx: AdminContext, params: dict) -> dict:
|
|
375
|
+
keys = ctx.api_keys.describe()
|
|
376
|
+
if ctx.scoped:
|
|
377
|
+
keys = [k for k in keys if k["org_id"] == ctx.caller_org]
|
|
378
|
+
return {
|
|
379
|
+
"keys": keys,
|
|
380
|
+
"source": ctx.api_keys.source,
|
|
381
|
+
"mutable": ctx.api_keys.mutable,
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def _apikey_create(ctx: AdminContext, params: dict) -> dict:
|
|
386
|
+
org_id = params.get("org_id")
|
|
387
|
+
if ctx.scoped:
|
|
388
|
+
# Un `org_admin` ne crée que pour sa propre organisation : accepter
|
|
389
|
+
# un `org_id` différent laisserait une entreprise émettre des clés
|
|
390
|
+
# au nom d'une autre.
|
|
391
|
+
if org_id and org_id != ctx.caller_org:
|
|
392
|
+
raise AdminError("Un org_admin ne peut créer de clé que pour sa propre organisation.")
|
|
393
|
+
org_id = ctx.caller_org
|
|
394
|
+
elif not org_id:
|
|
395
|
+
raise AdminError("org_id requis.")
|
|
396
|
+
try:
|
|
397
|
+
raw = ctx.api_keys.create(
|
|
398
|
+
org_id=org_id,
|
|
399
|
+
roles=params.get("roles") or ["service_account"],
|
|
400
|
+
permissions=params.get("permissions") or [],
|
|
401
|
+
label=params.get("label", ""),
|
|
402
|
+
)
|
|
403
|
+
except PermissionError as exc:
|
|
404
|
+
raise AdminError(str(exc)) from exc
|
|
405
|
+
|
|
406
|
+
# Unique occasion où la valeur en clair transite. Elle n'est pas
|
|
407
|
+
# journalisée et le Hub ne peut plus la retrouver ensuite.
|
|
408
|
+
return {"key": raw, "warning": "Cette clé ne sera plus jamais affichée."}
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def _apikey_revoke(ctx: AdminContext, params: dict) -> dict:
|
|
412
|
+
fingerprint = params.get("fingerprint")
|
|
413
|
+
if not fingerprint:
|
|
414
|
+
raise AdminError("fingerprint requis.")
|
|
415
|
+
if ctx.scoped:
|
|
416
|
+
matches = [k for k in ctx.api_keys.describe() if k["fingerprint"].startswith(fingerprint)]
|
|
417
|
+
if len(matches) != 1 or matches[0]["org_id"] != ctx.caller_org:
|
|
418
|
+
raise AdminError("Empreinte inconnue ou hors de votre organisation.")
|
|
419
|
+
try:
|
|
420
|
+
ok = ctx.api_keys.revoke(fingerprint)
|
|
421
|
+
except PermissionError as exc:
|
|
422
|
+
raise AdminError(str(exc)) from exc
|
|
423
|
+
if not ok:
|
|
424
|
+
raise AdminError("Empreinte inconnue ou ambiguë.")
|
|
425
|
+
return {"revoked": fingerprint}
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def _policy_to_dict(policy: GuardrailPolicy) -> dict:
|
|
429
|
+
return dataclasses.asdict(policy)
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def _resolve_target_org(ctx: AdminContext, params: dict) -> str:
|
|
433
|
+
"""
|
|
434
|
+
Organisation ciblée par une commande de garde-fous.
|
|
435
|
+
|
|
436
|
+
Un `org_admin` ne peut lire/modifier que sa propre organisation : un
|
|
437
|
+
`org_id` différent dans les paramètres est refusé plutôt qu'ignoré, pour
|
|
438
|
+
ne jamais laisser croire qu'une action a porté sur la bonne cible.
|
|
439
|
+
"""
|
|
440
|
+
org_id = params.get("org_id")
|
|
441
|
+
if ctx.scoped:
|
|
442
|
+
if org_id and org_id != ctx.caller_org:
|
|
443
|
+
raise AdminError("Un org_admin ne peut consulter/modifier que sa propre organisation.")
|
|
444
|
+
return ctx.caller_org
|
|
445
|
+
return org_id or ctx.my_org
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def _guardrails_policy(ctx: AdminContext, params: dict) -> dict:
|
|
449
|
+
if ctx.asimov_engine is None:
|
|
450
|
+
raise AdminError("Moteur de garde-fous indisponible sur ce Hub.")
|
|
451
|
+
org_id = _resolve_target_org(ctx, params)
|
|
452
|
+
return {"org_id": org_id, "policy": _policy_to_dict(ctx.asimov_engine.get_policy(org_id))}
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def _guardrails_set_policy(ctx: AdminContext, params: dict) -> dict:
|
|
456
|
+
"""
|
|
457
|
+
Met à jour la policy d'une organisation en ne remplaçant que les champs
|
|
458
|
+
fournis — les autres restent hérités de la policy actuelle de cette
|
|
459
|
+
organisation (ou de la policy par défaut du Hub si elle n'en a pas encore).
|
|
460
|
+
"""
|
|
461
|
+
if ctx.asimov_engine is None:
|
|
462
|
+
raise AdminError("Moteur de garde-fous indisponible sur ce Hub.")
|
|
463
|
+
org_id = _resolve_target_org(ctx, params)
|
|
464
|
+
|
|
465
|
+
current = _policy_to_dict(ctx.asimov_engine.get_policy(org_id))
|
|
466
|
+
editable = {f.name for f in dataclasses.fields(GuardrailPolicy)} - {"name"}
|
|
467
|
+
for key, value in params.items():
|
|
468
|
+
if key in editable:
|
|
469
|
+
current[key] = value
|
|
470
|
+
|
|
471
|
+
new_policy = GuardrailPolicy(**current)
|
|
472
|
+
ctx.asimov_engine.set_org_policy(org_id, new_policy)
|
|
473
|
+
return {"org_id": org_id, "policy": _policy_to_dict(new_policy)}
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
def _require_escrow(ctx: AdminContext):
|
|
477
|
+
if ctx.escrow_manager is None:
|
|
478
|
+
raise AdminError("Le module Escrow n'est pas actif sur ce Hub.")
|
|
479
|
+
return ctx.escrow_manager
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def _escrow_balance(ctx: AdminContext, params: dict) -> dict:
|
|
483
|
+
manager = _require_escrow(ctx)
|
|
484
|
+
org_id = _resolve_target_org(ctx, params)
|
|
485
|
+
currency = params.get("currency", "USD")
|
|
486
|
+
return {"org_id": org_id, "currency": currency, "balance": manager.ledger.balance(org_id, currency)}
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
def _escrow_grant(ctx: AdminContext, params: dict) -> dict:
|
|
490
|
+
"""
|
|
491
|
+
Crédite un solde SIMULÉ pour une organisation — un robinet de démo/test,
|
|
492
|
+
jamais un vrai mouvement d'argent (voir le docstring de `escrow.py`).
|
|
493
|
+
"""
|
|
494
|
+
manager = _require_escrow(ctx)
|
|
495
|
+
org_id = _resolve_target_org(ctx, params)
|
|
496
|
+
amount = params.get("amount")
|
|
497
|
+
if not amount:
|
|
498
|
+
raise AdminError("amount requis.")
|
|
499
|
+
currency = params.get("currency", "USD")
|
|
500
|
+
try:
|
|
501
|
+
new_balance = manager.ledger.grant(org_id, float(amount), currency)
|
|
502
|
+
except EscrowError as exc:
|
|
503
|
+
raise AdminError(str(exc)) from exc
|
|
504
|
+
return {"org_id": org_id, "currency": currency, "balance": new_balance, "simulated": True}
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
def _escrow_list(ctx: AdminContext, params: dict) -> dict:
|
|
508
|
+
manager = _require_escrow(ctx)
|
|
509
|
+
org_id = ctx.caller_org if ctx.scoped else params.get("org_id")
|
|
510
|
+
holds = manager.list_holds(org_id)
|
|
511
|
+
return {"holds": [h.to_dict() for h in holds], "total": len(holds)}
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
def _check_hold_in_scope(ctx: AdminContext, hold) -> None:
|
|
515
|
+
if ctx.scoped and ctx.caller_org not in (hold.payer_org, hold.payee_org):
|
|
516
|
+
raise AdminError("Ce séquestre est hors de votre organisation.")
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
def _escrow_release(ctx: AdminContext, params: dict) -> dict:
|
|
520
|
+
manager = _require_escrow(ctx)
|
|
521
|
+
task_id = params.get("task_id")
|
|
522
|
+
hold = manager.get(task_id) if task_id else None
|
|
523
|
+
if hold is None:
|
|
524
|
+
raise AdminError("Aucun séquestre pour cette tâche.")
|
|
525
|
+
_check_hold_in_scope(ctx, hold)
|
|
526
|
+
try:
|
|
527
|
+
released = manager.release(task_id)
|
|
528
|
+
except EscrowError as exc:
|
|
529
|
+
raise AdminError(str(exc)) from exc
|
|
530
|
+
return released.to_dict()
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
def _escrow_refund(ctx: AdminContext, params: dict) -> dict:
|
|
534
|
+
manager = _require_escrow(ctx)
|
|
535
|
+
task_id = params.get("task_id")
|
|
536
|
+
hold = manager.get(task_id) if task_id else None
|
|
537
|
+
if hold is None:
|
|
538
|
+
raise AdminError("Aucun séquestre pour cette tâche.")
|
|
539
|
+
_check_hold_in_scope(ctx, hold)
|
|
540
|
+
try:
|
|
541
|
+
refunded = manager.refund(task_id)
|
|
542
|
+
except EscrowError as exc:
|
|
543
|
+
raise AdminError(str(exc)) from exc
|
|
544
|
+
return refunded.to_dict()
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
# ----------------------------------------------------------------------
|
|
548
|
+
# Instantanés
|
|
549
|
+
# ----------------------------------------------------------------------
|
|
550
|
+
|
|
551
|
+
def _require_hub_admin(ctx: AdminContext, action: str) -> None:
|
|
552
|
+
"""
|
|
553
|
+
Un instantané est hub-wide par nature : il contient le registre, les
|
|
554
|
+
clés et les séquestres de TOUTES les organisations. Un `org_admin` qui
|
|
555
|
+
pourrait le créer lirait ses voisins ; s'il pouvait le restaurer, il
|
|
556
|
+
écraserait leur état. Ces commandes sont donc réservées à `admin`.
|
|
557
|
+
"""
|
|
558
|
+
if ctx.scoped:
|
|
559
|
+
raise AdminError(
|
|
560
|
+
f"'{action}' porte sur l'ensemble du Hub, toutes organisations "
|
|
561
|
+
f"confondues : le rôle 'admin' est requis, 'org_admin' ne suffit pas."
|
|
562
|
+
)
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
def _audit_head(ctx: AdminContext) -> dict:
|
|
566
|
+
chain = getattr(ctx.audit_log, "chain", None)
|
|
567
|
+
if not chain:
|
|
568
|
+
return {}
|
|
569
|
+
return {"index": chain[-1].index, "hash": chain[-1].hash}
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
def _capture(ctx: AdminContext) -> dict:
|
|
573
|
+
return snapshot_store.capture_state(
|
|
574
|
+
identity_registry=ctx.identity_registry,
|
|
575
|
+
task_registry=ctx.task_registry,
|
|
576
|
+
api_keys=ctx.api_keys,
|
|
577
|
+
asimov_engine=ctx.asimov_engine,
|
|
578
|
+
escrow_manager=ctx.escrow_manager,
|
|
579
|
+
)
|
|
580
|
+
|
|
581
|
+
|
|
582
|
+
def _snapshot_create(ctx: AdminContext, params: dict) -> dict:
|
|
583
|
+
_require_hub_admin(ctx, "snapshot.create")
|
|
584
|
+
name = params.get("name")
|
|
585
|
+
if not name:
|
|
586
|
+
raise AdminError("name requis.")
|
|
587
|
+
try:
|
|
588
|
+
manifest = snapshot_store.save(
|
|
589
|
+
name,
|
|
590
|
+
_capture(ctx),
|
|
591
|
+
hub_org=ctx.my_org,
|
|
592
|
+
audit_head=_audit_head(ctx),
|
|
593
|
+
directory=ctx.snapshot_dir,
|
|
594
|
+
passphrase=params.get("passphrase"),
|
|
595
|
+
overwrite=bool(params.get("overwrite", True)),
|
|
596
|
+
)
|
|
597
|
+
except SnapshotError as exc:
|
|
598
|
+
raise AdminError(str(exc)) from exc
|
|
599
|
+
|
|
600
|
+
if not manifest["encrypted"]:
|
|
601
|
+
manifest["warning"] = (
|
|
602
|
+
"Instantané non chiffré. Le fichier est en 0600 mais contient les "
|
|
603
|
+
"empreintes de clés d'API et l'inventaire de toutes les organisations : "
|
|
604
|
+
"fournissez `passphrase` avant de le sortir de cette machine."
|
|
605
|
+
)
|
|
606
|
+
return manifest
|
|
607
|
+
|
|
608
|
+
|
|
609
|
+
def _snapshot_list(ctx: AdminContext, params: dict) -> dict:
|
|
610
|
+
_require_hub_admin(ctx, "snapshot.list")
|
|
611
|
+
snapshots = snapshot_store.list_snapshots(ctx.snapshot_dir)
|
|
612
|
+
return {"snapshots": snapshots, "total": len(snapshots)}
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
def _snapshot_delete(ctx: AdminContext, params: dict) -> dict:
|
|
616
|
+
_require_hub_admin(ctx, "snapshot.delete")
|
|
617
|
+
name = params.get("name")
|
|
618
|
+
if not name:
|
|
619
|
+
raise AdminError("name requis.")
|
|
620
|
+
try:
|
|
621
|
+
deleted = snapshot_store.delete(name, ctx.snapshot_dir)
|
|
622
|
+
except SnapshotError as exc:
|
|
623
|
+
raise AdminError(str(exc)) from exc
|
|
624
|
+
if not deleted:
|
|
625
|
+
raise AdminError(f"Instantané inconnu : '{name}'.")
|
|
626
|
+
return {"deleted": name}
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
def _snapshot_restore(ctx: AdminContext, params: dict) -> dict:
|
|
630
|
+
"""
|
|
631
|
+
Réinstalle un instantané, après en avoir pris un de sécurité.
|
|
632
|
+
|
|
633
|
+
Le filet de sécurité est pris AVANT toute écriture, avec la même
|
|
634
|
+
passphrase que celle fournie pour la lecture : une restauration ratée
|
|
635
|
+
reste elle-même réversible. `safety_snapshot=False` le désactive.
|
|
636
|
+
"""
|
|
637
|
+
_require_hub_admin(ctx, "snapshot.restore")
|
|
638
|
+
name = params.get("name")
|
|
639
|
+
if not name:
|
|
640
|
+
raise AdminError("name requis.")
|
|
641
|
+
passphrase = params.get("passphrase")
|
|
642
|
+
|
|
643
|
+
try:
|
|
644
|
+
manifest, state = snapshot_store.load(
|
|
645
|
+
name, directory=ctx.snapshot_dir, passphrase=passphrase,
|
|
646
|
+
)
|
|
647
|
+
except SnapshotError as exc:
|
|
648
|
+
raise AdminError(str(exc)) from exc
|
|
649
|
+
|
|
650
|
+
safety_name = None
|
|
651
|
+
if params.get("safety_snapshot", True):
|
|
652
|
+
safety_name = f"pre-restore-{int(time.time())}"
|
|
653
|
+
try:
|
|
654
|
+
snapshot_store.save(
|
|
655
|
+
safety_name, _capture(ctx), hub_org=ctx.my_org,
|
|
656
|
+
audit_head=_audit_head(ctx), directory=ctx.snapshot_dir,
|
|
657
|
+
passphrase=passphrase,
|
|
658
|
+
)
|
|
659
|
+
except SnapshotError as exc:
|
|
660
|
+
raise AdminError(
|
|
661
|
+
f"Filet de sécurité impossible à écrire ({exc}) : restauration "
|
|
662
|
+
f"abandonnée. Passez `safety_snapshot: false` pour forcer."
|
|
663
|
+
) from exc
|
|
664
|
+
|
|
665
|
+
report = snapshot_store.apply_state(
|
|
666
|
+
state,
|
|
667
|
+
identity_registry=ctx.identity_registry,
|
|
668
|
+
task_registry=ctx.task_registry,
|
|
669
|
+
connected_agents=ctx.agents,
|
|
670
|
+
store=ctx.store,
|
|
671
|
+
api_keys=ctx.api_keys,
|
|
672
|
+
asimov_engine=ctx.asimov_engine,
|
|
673
|
+
escrow_manager=ctx.escrow_manager,
|
|
674
|
+
)
|
|
675
|
+
|
|
676
|
+
# La chaîne d'audit vivante n'est jamais remplacée : elle est
|
|
677
|
+
# prolongée. Un instantané ne peut donc pas effacer la trace de sa
|
|
678
|
+
# propre restauration.
|
|
679
|
+
if ctx.audit_log is not None:
|
|
680
|
+
ctx.audit_log.log("SNAPSHOT_RESTORED", "hub", None, {
|
|
681
|
+
"snapshot": name,
|
|
682
|
+
"snapshot_created_at": manifest.get("created_at"),
|
|
683
|
+
"safety_snapshot": safety_name,
|
|
684
|
+
"restored": report.get("restored"),
|
|
685
|
+
"skipped": [s["what"] for s in report.get("skipped", [])],
|
|
686
|
+
})
|
|
687
|
+
|
|
688
|
+
return {
|
|
689
|
+
"restored_from": name,
|
|
690
|
+
"manifest": manifest,
|
|
691
|
+
"safety_snapshot": safety_name,
|
|
692
|
+
"audit_preserved": True,
|
|
693
|
+
**report,
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
|
|
697
|
+
HANDLERS: dict[str, Callable[..., Any]] = {
|
|
698
|
+
"hub.info": _hub_info,
|
|
699
|
+
"agents.list": _agents_list,
|
|
700
|
+
"agent.disconnect": _agent_disconnect,
|
|
701
|
+
"tasks.list": _tasks_list,
|
|
702
|
+
"task.cancel": _task_cancel,
|
|
703
|
+
"task.retry": _task_retry,
|
|
704
|
+
"audit.list": _audit_list,
|
|
705
|
+
"audit.verify": _audit_verify,
|
|
706
|
+
"apikeys.list": _apikeys_list,
|
|
707
|
+
"apikey.create": _apikey_create,
|
|
708
|
+
"apikey.revoke": _apikey_revoke,
|
|
709
|
+
"guardrails.policy": _guardrails_policy,
|
|
710
|
+
"guardrails.set_policy": _guardrails_set_policy,
|
|
711
|
+
"escrow.balance": _escrow_balance,
|
|
712
|
+
"escrow.grant": _escrow_grant,
|
|
713
|
+
"escrow.list": _escrow_list,
|
|
714
|
+
"escrow.release": _escrow_release,
|
|
715
|
+
"escrow.refund": _escrow_refund,
|
|
716
|
+
"snapshot.create": _snapshot_create,
|
|
717
|
+
"snapshot.list": _snapshot_list,
|
|
718
|
+
"snapshot.restore": _snapshot_restore,
|
|
719
|
+
"snapshot.delete": _snapshot_delete,
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
|
|
723
|
+
async def execute(command: str, params: dict, ctx: AdminContext) -> dict:
|
|
724
|
+
"""Exécute une commande d'administration déjà autorisée."""
|
|
725
|
+
handler = HANDLERS.get(command)
|
|
726
|
+
if handler is None:
|
|
727
|
+
raise AdminError(f"Commande inconnue : '{command}'. "
|
|
728
|
+
f"Disponibles : {', '.join(sorted(HANDLERS))}")
|
|
729
|
+
|
|
730
|
+
result = handler(ctx, params or {})
|
|
731
|
+
if hasattr(result, "__await__"):
|
|
732
|
+
result = await result
|
|
733
|
+
return result
|