toolframe-core 0.6.2__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.
- toolframe_core/__init__.py +3 -0
- toolframe_core/_state_postgres.py +420 -0
- toolframe_core/_state_sqlite.py +339 -0
- toolframe_core/access.py +513 -0
- toolframe_core/api.py +700 -0
- toolframe_core/bundled_skills/toolframe-plugin-design/LICENSE +21 -0
- toolframe_core/bundled_skills/toolframe-plugin-design/SKILL.md +59 -0
- toolframe_core/bundled_skills/toolframe-plugin-design/agents/openai.yaml +4 -0
- toolframe_core/bundled_skills/toolframe-plugin-design/assets/sample-plugin/LICENSE +21 -0
- toolframe_core/bundled_skills/toolframe-plugin-design/assets/sample-plugin/pyproject.toml +21 -0
- toolframe_core/bundled_skills/toolframe-plugin-design/assets/sample-plugin/src/toolframe_sample_notes/__init__.py +1 -0
- toolframe_core/bundled_skills/toolframe-plugin-design/assets/sample-plugin/src/toolframe_sample_notes/docs/usage.md +13 -0
- toolframe_core/bundled_skills/toolframe-plugin-design/assets/sample-plugin/src/toolframe_sample_notes/migrations/0001_init.sql +5 -0
- toolframe_core/bundled_skills/toolframe-plugin-design/assets/sample-plugin/src/toolframe_sample_notes/page.py +25 -0
- toolframe_core/bundled_skills/toolframe-plugin-design/assets/sample-plugin/src/toolframe_sample_notes/plugin.py +119 -0
- toolframe_core/bundled_skills/toolframe-plugin-design/assets/sample-plugin/src/toolframe_sample_notes/service.py +48 -0
- toolframe_core/bundled_skills/toolframe-plugin-design/references/design.md +60 -0
- toolframe_core/bundled_skills/toolframe-plugin-design/references/operations.md +133 -0
- toolframe_core/bundled_skills/toolframe-plugin-design/references/plugin-api.md +186 -0
- toolframe_core/cli.py +681 -0
- toolframe_core/config.py +267 -0
- toolframe_core/diagnostics.py +200 -0
- toolframe_core/graph.py +207 -0
- toolframe_core/host.py +2610 -0
- toolframe_core/maintenance.py +868 -0
- toolframe_core/mcp_access.py +259 -0
- toolframe_core/network.py +97 -0
- toolframe_core/oidc_access.py +187 -0
- toolframe_core/packages.py +728 -0
- toolframe_core/process.py +212 -0
- toolframe_core/redaction.py +120 -0
- toolframe_core/scaffold.py +142 -0
- toolframe_core/skills.py +210 -0
- toolframe_core/state.py +226 -0
- toolframe_core/state_admin.py +270 -0
- toolframe_core/stores.py +88 -0
- toolframe_core/templates.json +6 -0
- toolframe_core/templates.py +56 -0
- toolframe_core-0.6.2.dist-info/METADATA +465 -0
- toolframe_core-0.6.2.dist-info/RECORD +46 -0
- toolframe_core-0.6.2.dist-info/WHEEL +4 -0
- toolframe_core-0.6.2.dist-info/entry_points.txt +2 -0
- toolframe_core-0.6.2.dist-info/licenses/LICENSE +202 -0
- toolframe_core-0.6.2.dist-info/licenses/NOTICE +14 -0
- toolframe_core-0.6.2.dist-info/licenses/src/toolframe_core/bundled_skills/toolframe-plugin-design/LICENSE +21 -0
- toolframe_core-0.6.2.dist-info/licenses/src/toolframe_core/bundled_skills/toolframe-plugin-design/assets/sample-plugin/LICENSE +21 -0
toolframe_core/access.py
ADDED
|
@@ -0,0 +1,513 @@
|
|
|
1
|
+
"""Host-owned identity admission; plugin-owned credentials and session persistence."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hmac
|
|
6
|
+
import secrets
|
|
7
|
+
import time
|
|
8
|
+
from collections import OrderedDict
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from urllib.parse import urlsplit
|
|
11
|
+
|
|
12
|
+
from toolframe_sdk import SYSTEM_SCOPES, PluginError, Principal
|
|
13
|
+
|
|
14
|
+
from .config import atomic_write
|
|
15
|
+
from .network import loopback
|
|
16
|
+
|
|
17
|
+
OPERATOR_COOKIE = "toolframe_session"
|
|
18
|
+
PASSWORD_COOKIE = "toolframe_access"
|
|
19
|
+
CONTEXT_HEADER = "x-toolframe-context"
|
|
20
|
+
DENIALS = {
|
|
21
|
+
"invalid_credentials",
|
|
22
|
+
"session_invalid",
|
|
23
|
+
"password_conflict",
|
|
24
|
+
"credential_missing",
|
|
25
|
+
"credential_revoked",
|
|
26
|
+
"invalid_request",
|
|
27
|
+
"forbidden",
|
|
28
|
+
"rate_limited",
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True)
|
|
33
|
+
class Identity:
|
|
34
|
+
local: bool = False
|
|
35
|
+
operator: bool = False
|
|
36
|
+
session: str = ""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def operation_scopes(action, params=None):
|
|
40
|
+
params = params or {}
|
|
41
|
+
if action == "plan":
|
|
42
|
+
return operation_scopes(params.get("action", "apply"))
|
|
43
|
+
if action in {
|
|
44
|
+
"status",
|
|
45
|
+
"operation",
|
|
46
|
+
"operations",
|
|
47
|
+
"logs",
|
|
48
|
+
"composition",
|
|
49
|
+
"readiness",
|
|
50
|
+
}:
|
|
51
|
+
return ("system.plugins.read",)
|
|
52
|
+
if action in {"migrate-plan", "migrate-apply"}:
|
|
53
|
+
return ("system.migrations.manage",)
|
|
54
|
+
return ("system.plugins.manage",)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class Access:
|
|
58
|
+
def __init__(self, host):
|
|
59
|
+
self.host = host
|
|
60
|
+
self.contexts: dict[str, Identity] = {}
|
|
61
|
+
self.failures = {}
|
|
62
|
+
self.login_attempts = OrderedDict()
|
|
63
|
+
self.login_slots = 0
|
|
64
|
+
|
|
65
|
+
def catalog(self):
|
|
66
|
+
catalog = {
|
|
67
|
+
key: {"title": title, "category": "system", "owner": "system"}
|
|
68
|
+
for key, title in SYSTEM_SCOPES.items()
|
|
69
|
+
}
|
|
70
|
+
for owner, definition in self.host.graph.definitions.items():
|
|
71
|
+
for name, scope in definition.authorization.scopes.items():
|
|
72
|
+
catalog[name] = {**scope.model_dump(), "owner": owner}
|
|
73
|
+
return catalog
|
|
74
|
+
|
|
75
|
+
def identity(self, connection):
|
|
76
|
+
authorization = connection.headers.get("authorization", "")
|
|
77
|
+
token = (
|
|
78
|
+
authorization.removeprefix("Bearer ")
|
|
79
|
+
if authorization.startswith("Bearer ")
|
|
80
|
+
else connection.cookies.get(OPERATOR_COOKIE, "")
|
|
81
|
+
)
|
|
82
|
+
try:
|
|
83
|
+
local = bool(connection.client and loopback(connection.client.host))
|
|
84
|
+
except ValueError:
|
|
85
|
+
local = False
|
|
86
|
+
# A public virtual host is remote even if its proxy omits forwarded headers.
|
|
87
|
+
public_url = self.host.composition.application.public_url
|
|
88
|
+
if (
|
|
89
|
+
public_url
|
|
90
|
+
and connection.headers.get("host", "").lower()
|
|
91
|
+
== urlsplit(public_url).netloc.lower()
|
|
92
|
+
):
|
|
93
|
+
try:
|
|
94
|
+
local = local and loopback(urlsplit(public_url).hostname)
|
|
95
|
+
except ValueError:
|
|
96
|
+
local = False
|
|
97
|
+
# A reverse proxy's loopback socket is not a local human operator.
|
|
98
|
+
if any(
|
|
99
|
+
name in connection.headers
|
|
100
|
+
for name in (
|
|
101
|
+
"forwarded",
|
|
102
|
+
"x-forwarded-for",
|
|
103
|
+
"x-forwarded-host",
|
|
104
|
+
"x-forwarded-proto",
|
|
105
|
+
)
|
|
106
|
+
):
|
|
107
|
+
local = False
|
|
108
|
+
return Identity(
|
|
109
|
+
local=local and self.host.composition.access.local_control,
|
|
110
|
+
operator=hmac.compare_digest(
|
|
111
|
+
token.encode("utf-8"), self.host.token.encode("utf-8")
|
|
112
|
+
),
|
|
113
|
+
session=connection.cookies.get(PASSWORD_COOKIE, ""),
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
def register(self, identity):
|
|
117
|
+
if len(self.contexts) >= 4096:
|
|
118
|
+
raise PluginError("rate_limited", "Too many Dashboard connections")
|
|
119
|
+
reference = secrets.token_urlsafe(32)
|
|
120
|
+
self.contexts[reference] = identity
|
|
121
|
+
return reference
|
|
122
|
+
|
|
123
|
+
def context(self, reference):
|
|
124
|
+
identity = self.contexts.get(reference)
|
|
125
|
+
if identity is None:
|
|
126
|
+
raise PluginError(
|
|
127
|
+
"authentication_required", "Dashboard connection expired; reconnect"
|
|
128
|
+
)
|
|
129
|
+
return identity
|
|
130
|
+
|
|
131
|
+
def chain(self):
|
|
132
|
+
policy = self.host.composition.access
|
|
133
|
+
return (
|
|
134
|
+
(policy.auth_provider, "auth"),
|
|
135
|
+
(policy.dashboard_auth, "dashboard-auth"),
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
async def mode(self):
|
|
139
|
+
host, policy = self.host, self.host.composition.access
|
|
140
|
+
if policy.mode == "public":
|
|
141
|
+
return {
|
|
142
|
+
"mode": "public",
|
|
143
|
+
"reason": "configured_public",
|
|
144
|
+
"configured": "public",
|
|
145
|
+
}
|
|
146
|
+
problems, transitioning = [], False
|
|
147
|
+
for name, kind in self.chain():
|
|
148
|
+
desired = host.composition.plugins.get(name)
|
|
149
|
+
runtime = host.runtimes.get(name)
|
|
150
|
+
if (
|
|
151
|
+
not desired
|
|
152
|
+
or not desired.enabled
|
|
153
|
+
or (await host.state.plugin(name))["stopped"]
|
|
154
|
+
):
|
|
155
|
+
problems.append(f"{name}: disabled or not installed")
|
|
156
|
+
continue
|
|
157
|
+
if runtime and self.failures.get(name) == runtime.generation:
|
|
158
|
+
problems.append(f"{name}: auth decision failed")
|
|
159
|
+
continue
|
|
160
|
+
if (
|
|
161
|
+
host.healthy_runtime(name)
|
|
162
|
+
and runtime.candidate["definition"].get("provider_kind") == kind
|
|
163
|
+
):
|
|
164
|
+
continue
|
|
165
|
+
if host.starting or name in host.transitioning:
|
|
166
|
+
transitioning = True
|
|
167
|
+
else:
|
|
168
|
+
problems.append(f"{name}: unavailable")
|
|
169
|
+
if problems:
|
|
170
|
+
return {
|
|
171
|
+
"mode": "waiting" if policy.failure_mode == "closed" else "public",
|
|
172
|
+
"reason": "auth_unavailable",
|
|
173
|
+
"configured": "protected",
|
|
174
|
+
"detail": "; ".join(problems),
|
|
175
|
+
}
|
|
176
|
+
return {
|
|
177
|
+
"mode": "waiting" if transitioning else "protected",
|
|
178
|
+
"reason": "auth_transition" if transitioning else "configured_protected",
|
|
179
|
+
"configured": "protected",
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async def mark_failed(self, name):
|
|
183
|
+
runtime = self.host.runtimes.get(name)
|
|
184
|
+
if not runtime:
|
|
185
|
+
return
|
|
186
|
+
self.failures[name] = runtime.generation
|
|
187
|
+
runtime.health = {"runtime": "failed", "error": "Auth decision service failed"}
|
|
188
|
+
for member in self.host.graph.dependents({name}):
|
|
189
|
+
if active := self.host.runtimes.get(member):
|
|
190
|
+
active.admitted = False
|
|
191
|
+
self.host.gated.add(member)
|
|
192
|
+
(
|
|
193
|
+
await self.host.record(
|
|
194
|
+
active, "failed" if member == name else "blocked"
|
|
195
|
+
)
|
|
196
|
+
)
|
|
197
|
+
self.host.health_publication_pending = True
|
|
198
|
+
|
|
199
|
+
async def auth(self, method, params, *, principal=None):
|
|
200
|
+
name = self.host.composition.access.auth_provider
|
|
201
|
+
if not self.host.healthy_runtime(name):
|
|
202
|
+
raise PluginError(
|
|
203
|
+
"auth_unavailable", "Authentication provider is unavailable"
|
|
204
|
+
)
|
|
205
|
+
try:
|
|
206
|
+
result = await self.host.call_service(
|
|
207
|
+
name, "toolframe.auth", method, params, principal=principal
|
|
208
|
+
)
|
|
209
|
+
if not isinstance(result, dict):
|
|
210
|
+
raise TypeError("Invalid auth response")
|
|
211
|
+
if method in {"resolve", "login"}:
|
|
212
|
+
actor = Principal.model_validate(result["principal"])
|
|
213
|
+
if (
|
|
214
|
+
actor.kind not in {"password", "user"}
|
|
215
|
+
or not actor.credential_id
|
|
216
|
+
or (actor.kind == "user" and not actor.user_id)
|
|
217
|
+
):
|
|
218
|
+
raise ValueError("Invalid credential decision")
|
|
219
|
+
if method == "login" and (
|
|
220
|
+
not isinstance(result.get("token"), str)
|
|
221
|
+
or len(result["token"]) < 32
|
|
222
|
+
or not isinstance(result.get("expires_at"), (int, float))
|
|
223
|
+
):
|
|
224
|
+
raise ValueError("Invalid session decision")
|
|
225
|
+
return result
|
|
226
|
+
except PluginError as error:
|
|
227
|
+
if error.code in DENIALS or (
|
|
228
|
+
error.code in {"plugin_reloading", "dependency_unavailable"}
|
|
229
|
+
and (self.host.starting or name in self.host.transitioning)
|
|
230
|
+
):
|
|
231
|
+
raise
|
|
232
|
+
(await self.mark_failed(name))
|
|
233
|
+
raise PluginError(
|
|
234
|
+
"auth_unavailable", "Authentication provider failed"
|
|
235
|
+
) from None
|
|
236
|
+
except Exception: # noqa: BLE001 - verifier faults must obey the configured failure policy
|
|
237
|
+
(await self.mark_failed(name))
|
|
238
|
+
raise PluginError(
|
|
239
|
+
"auth_unavailable", "Authentication provider failed"
|
|
240
|
+
) from None
|
|
241
|
+
|
|
242
|
+
async def resolve(self, identity, *, effective=True):
|
|
243
|
+
if identity.local and self.host.composition.access.local_control:
|
|
244
|
+
return Principal(kind="local")
|
|
245
|
+
if identity.operator:
|
|
246
|
+
return Principal(kind="operator")
|
|
247
|
+
actor = Principal(kind="anonymous")
|
|
248
|
+
if identity.session:
|
|
249
|
+
try:
|
|
250
|
+
actor = Principal.model_validate(
|
|
251
|
+
(await self.auth("resolve", {"token": identity.session}))[
|
|
252
|
+
"principal"
|
|
253
|
+
]
|
|
254
|
+
)
|
|
255
|
+
except PluginError as error:
|
|
256
|
+
if error.code not in {
|
|
257
|
+
"session_invalid",
|
|
258
|
+
"auth_unavailable",
|
|
259
|
+
"plugin_reloading",
|
|
260
|
+
"dependency_unavailable",
|
|
261
|
+
}:
|
|
262
|
+
raise
|
|
263
|
+
if not effective:
|
|
264
|
+
return actor
|
|
265
|
+
mode = await self.mode()
|
|
266
|
+
if mode["mode"] == "waiting":
|
|
267
|
+
raise PluginError("auth_transition", "Access is preparing; retry shortly")
|
|
268
|
+
if mode["mode"] == "public":
|
|
269
|
+
return actor.model_copy(update={"all_business": True})
|
|
270
|
+
if actor.kind == "anonymous":
|
|
271
|
+
raise PluginError(
|
|
272
|
+
"authentication_required", "Sign in with an access password"
|
|
273
|
+
)
|
|
274
|
+
return actor
|
|
275
|
+
|
|
276
|
+
async def admit(self, identity, scopes=(), *, activity=False, effective=True):
|
|
277
|
+
actor = await self.resolve(identity, effective=effective)
|
|
278
|
+
if not actor.allows(scopes, self.catalog()):
|
|
279
|
+
if actor.kind == "anonymous":
|
|
280
|
+
raise PluginError(
|
|
281
|
+
"authentication_required",
|
|
282
|
+
"Operator or scoped password sign-in required",
|
|
283
|
+
)
|
|
284
|
+
raise PluginError(
|
|
285
|
+
"forbidden", "Your account does not grant the required scope"
|
|
286
|
+
)
|
|
287
|
+
if activity and actor.kind in {"password", "user"}:
|
|
288
|
+
# Check/touch only after the requested operation's scopes were granted.
|
|
289
|
+
# Public business access continues even if the optional session expires.
|
|
290
|
+
try:
|
|
291
|
+
await self.auth(
|
|
292
|
+
"resolve", {"token": identity.session, "activity": True}
|
|
293
|
+
)
|
|
294
|
+
except PluginError:
|
|
295
|
+
if (await self.mode())["mode"] != "public" or any(
|
|
296
|
+
self.catalog().get(s, {}).get("category") == "system"
|
|
297
|
+
for s in scopes
|
|
298
|
+
):
|
|
299
|
+
raise
|
|
300
|
+
return actor
|
|
301
|
+
|
|
302
|
+
async def presentation(self, identity):
|
|
303
|
+
try:
|
|
304
|
+
actor = await self.resolve(identity)
|
|
305
|
+
except PluginError as error:
|
|
306
|
+
if error.code not in {"authentication_required", "auth_transition"}:
|
|
307
|
+
raise
|
|
308
|
+
actor = Principal(kind="anonymous")
|
|
309
|
+
catalog = self.catalog()
|
|
310
|
+
return {
|
|
311
|
+
**(await self.mode()),
|
|
312
|
+
"local_control": actor.kind == "local",
|
|
313
|
+
"actor": actor.kind,
|
|
314
|
+
"user_id": actor.user_id,
|
|
315
|
+
"allowed_scopes": [s for s in catalog if actor.allows([s], catalog)],
|
|
316
|
+
"login_url": "/_toolframe/auth/login",
|
|
317
|
+
"logout_url": "/_toolframe/auth/logout",
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
async def login(self, password, peer):
|
|
321
|
+
now = time.monotonic()
|
|
322
|
+
# Bounded peer buckets + global concurrency. Rejecting load is not an outage.
|
|
323
|
+
for key in list(self.login_attempts):
|
|
324
|
+
if now - self.login_attempts[key][0] >= 60:
|
|
325
|
+
del self.login_attempts[key]
|
|
326
|
+
start, count = self.login_attempts.get(peer, (now, 0))
|
|
327
|
+
if (
|
|
328
|
+
count >= 10
|
|
329
|
+
or self.login_slots >= 2
|
|
330
|
+
or (peer not in self.login_attempts and len(self.login_attempts) >= 4096)
|
|
331
|
+
):
|
|
332
|
+
raise PluginError("rate_limited", "Too many login attempts; retry later")
|
|
333
|
+
self.login_attempts[peer] = (start, count + 1)
|
|
334
|
+
if (await self.mode())["mode"] == "waiting":
|
|
335
|
+
raise PluginError("auth_transition", "Access is preparing; retry shortly")
|
|
336
|
+
self.login_slots += 1
|
|
337
|
+
try:
|
|
338
|
+
return await self.auth("login", {"password": password})
|
|
339
|
+
finally:
|
|
340
|
+
self.login_slots -= 1
|
|
341
|
+
|
|
342
|
+
async def manage(self, identity, action, params):
|
|
343
|
+
if action.startswith("mcp."):
|
|
344
|
+
return await self.host.mcp_access.manage(identity, action[4:], params)
|
|
345
|
+
if action == "presentation":
|
|
346
|
+
return await self.presentation(identity)
|
|
347
|
+
if action == "user_catalog":
|
|
348
|
+
actor = await self.admit(identity, ["system.auth.users"], effective=False)
|
|
349
|
+
catalog = self.catalog()
|
|
350
|
+
return {
|
|
351
|
+
"scopes": {
|
|
352
|
+
name: spec
|
|
353
|
+
for name, spec in catalog.items()
|
|
354
|
+
if actor.allows([name], catalog)
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
if action in {"users", "user_update", "grants", "revoke_grant"}:
|
|
358
|
+
scope = (
|
|
359
|
+
"system.auth.users"
|
|
360
|
+
if action in {"users", "user_update"}
|
|
361
|
+
else "system.auth.self"
|
|
362
|
+
)
|
|
363
|
+
actor = await self.admit(
|
|
364
|
+
identity,
|
|
365
|
+
[scope],
|
|
366
|
+
effective=False,
|
|
367
|
+
activity=action not in {"users", "grants"},
|
|
368
|
+
)
|
|
369
|
+
return await self.auth(action, params, principal=actor)
|
|
370
|
+
if action == "catalog":
|
|
371
|
+
actor = await self.admit(
|
|
372
|
+
identity, ["system.auth.passwords"], effective=False
|
|
373
|
+
)
|
|
374
|
+
return {
|
|
375
|
+
"scopes": self.catalog(),
|
|
376
|
+
"policy": self.host.composition.access.model_dump(),
|
|
377
|
+
"policy_options": {"failure_modes": ["closed", "public"]},
|
|
378
|
+
"entrances": {
|
|
379
|
+
"dashboard": await self.mode(),
|
|
380
|
+
"mcp": self.host.mcp_access.status(),
|
|
381
|
+
"actor": actor.kind,
|
|
382
|
+
"declared_public_api": sorted(
|
|
383
|
+
f"/api/{owner}/{service_name}/{method_name}"
|
|
384
|
+
for owner, definition in self.host.graph.definitions.items()
|
|
385
|
+
for service_name, service in definition.services.items()
|
|
386
|
+
for method_name, method in service.methods.items()
|
|
387
|
+
if method.audience == "business" and method.public_api
|
|
388
|
+
),
|
|
389
|
+
},
|
|
390
|
+
"revision": self.host.config.revision,
|
|
391
|
+
}
|
|
392
|
+
if action == "policy":
|
|
393
|
+
await self.admit(
|
|
394
|
+
identity, ["system.settings.manage"], activity=True, effective=False
|
|
395
|
+
)
|
|
396
|
+
if (
|
|
397
|
+
not isinstance(params.get("mode"), str)
|
|
398
|
+
or params["mode"] not in {"public", "protected"}
|
|
399
|
+
or not isinstance(params.get("local_control", True), bool)
|
|
400
|
+
or (
|
|
401
|
+
"failure_mode" in params
|
|
402
|
+
and (
|
|
403
|
+
not isinstance(params["failure_mode"], str)
|
|
404
|
+
or params["failure_mode"] not in {"closed", "public"}
|
|
405
|
+
)
|
|
406
|
+
)
|
|
407
|
+
):
|
|
408
|
+
raise PluginError(
|
|
409
|
+
"invalid_request",
|
|
410
|
+
"Supply public/protected mode, boolean local_control and optional closed/public failure_mode",
|
|
411
|
+
)
|
|
412
|
+
async with self.host.mutation:
|
|
413
|
+
await self.admit(identity, ["system.settings.manage"], effective=False)
|
|
414
|
+
|
|
415
|
+
def edit(document):
|
|
416
|
+
access = document.setdefault("access", {})
|
|
417
|
+
if "failure_mode" in params:
|
|
418
|
+
access["failure_mode"] = params["failure_mode"]
|
|
419
|
+
elif (
|
|
420
|
+
params["mode"] == "protected"
|
|
421
|
+
and access.get("mode") != "protected"
|
|
422
|
+
):
|
|
423
|
+
access.setdefault("failure_mode", "closed")
|
|
424
|
+
access["mode"] = params["mode"]
|
|
425
|
+
access["local_control"] = params.get("local_control", True)
|
|
426
|
+
|
|
427
|
+
self.host.composition, self.host.revision = self.host.config.edit(
|
|
428
|
+
params.get("expected_revision"), edit
|
|
429
|
+
)
|
|
430
|
+
(await self.host.write_lock())
|
|
431
|
+
return await self.mode()
|
|
432
|
+
if action not in {"create", "list", "update", "revoke"}:
|
|
433
|
+
raise PluginError("invalid_request", "Unknown password operation")
|
|
434
|
+
actor = await self.admit(
|
|
435
|
+
identity,
|
|
436
|
+
["system.auth.passwords"],
|
|
437
|
+
activity=action != "list",
|
|
438
|
+
effective=False,
|
|
439
|
+
)
|
|
440
|
+
if action != "create":
|
|
441
|
+
return await self.auth(action, params, principal=actor)
|
|
442
|
+
# Serialize with composition operations; no password ever enters their journal.
|
|
443
|
+
async with self.host.mutation:
|
|
444
|
+
actor = await self.admit(
|
|
445
|
+
identity, ["system.auth.passwords"], effective=False
|
|
446
|
+
)
|
|
447
|
+
revision = self.host.config.revision
|
|
448
|
+
if params.get("expected_revision") != revision:
|
|
449
|
+
raise PluginError(
|
|
450
|
+
"stale_revision",
|
|
451
|
+
"Refresh access settings before creating a password",
|
|
452
|
+
)
|
|
453
|
+
result = await self.auth(action, params, principal=actor)
|
|
454
|
+
if result["first_password"]:
|
|
455
|
+
try:
|
|
456
|
+
|
|
457
|
+
def protect(document):
|
|
458
|
+
access = document.setdefault("access", {})
|
|
459
|
+
access.setdefault("failure_mode", "closed")
|
|
460
|
+
access["mode"] = "protected"
|
|
461
|
+
|
|
462
|
+
self.host.composition, self.host.revision = self.host.config.edit(
|
|
463
|
+
revision, protect
|
|
464
|
+
)
|
|
465
|
+
(await self.host.write_lock())
|
|
466
|
+
except PluginError, OSError, ValueError:
|
|
467
|
+
# Record was committed, so return its ID/one-time secret with an
|
|
468
|
+
# explicit recovery instruction instead of suggesting a blind retry.
|
|
469
|
+
result["policy_error"] = (
|
|
470
|
+
"Password created, but the policy update could not be confirmed. Refresh and explicitly confirm protected mode."
|
|
471
|
+
)
|
|
472
|
+
result["access"] = await self.mode()
|
|
473
|
+
return result
|
|
474
|
+
|
|
475
|
+
def private_secrets(self, definition, *, store_exists=None):
|
|
476
|
+
result = {}
|
|
477
|
+
for name in definition.private_secrets:
|
|
478
|
+
path = self.host.home / ".toolframe/secrets" / definition.id / f"{name}.key"
|
|
479
|
+
if path.is_symlink():
|
|
480
|
+
raise PluginError(
|
|
481
|
+
"secret_invalid",
|
|
482
|
+
"Private key must be an owner-controlled regular file",
|
|
483
|
+
)
|
|
484
|
+
if not path.exists():
|
|
485
|
+
if store_exists or (
|
|
486
|
+
store_exists is None
|
|
487
|
+
and (
|
|
488
|
+
self.host.home / "data/plugins" / definition.id / "data.sqlite"
|
|
489
|
+
).exists()
|
|
490
|
+
):
|
|
491
|
+
raise PluginError(
|
|
492
|
+
"secret_missing",
|
|
493
|
+
"Restore the plugin private key from the application backup",
|
|
494
|
+
)
|
|
495
|
+
atomic_write(path, secrets.token_hex(32))
|
|
496
|
+
path.parent.chmod(0o700)
|
|
497
|
+
if path.stat().st_mode & 0o077:
|
|
498
|
+
raise PluginError(
|
|
499
|
+
"secret_permissions", "Private key must have mode 0600"
|
|
500
|
+
)
|
|
501
|
+
value = path.read_text().strip()
|
|
502
|
+
if len(value) != 64:
|
|
503
|
+
raise PluginError(
|
|
504
|
+
"secret_invalid", "Private key must contain 32 hex-encoded bytes"
|
|
505
|
+
)
|
|
506
|
+
try:
|
|
507
|
+
bytes.fromhex(value)
|
|
508
|
+
except ValueError:
|
|
509
|
+
raise PluginError(
|
|
510
|
+
"secret_invalid", "Invalid private key encoding"
|
|
511
|
+
) from None
|
|
512
|
+
result[name] = value
|
|
513
|
+
return result
|