keywright-server 0.0.1__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.
- keywright_server/__init__.py +3 -0
- keywright_server/__main__.py +71 -0
- keywright_server/access.py +131 -0
- keywright_server/access_view.py +144 -0
- keywright_server/actions.py +30 -0
- keywright_server/approvals.py +433 -0
- keywright_server/audit.py +58 -0
- keywright_server/bundles.py +87 -0
- keywright_server/config.py +102 -0
- keywright_server/db.py +119 -0
- keywright_server/discovery.py +277 -0
- keywright_server/events.py +78 -0
- keywright_server/friction.py +242 -0
- keywright_server/integrations.py +64 -0
- keywright_server/inventory.py +415 -0
- keywright_server/inventory_mcp.py +288 -0
- keywright_server/jev.py +80 -0
- keywright_server/knowledge.py +403 -0
- keywright_server/knowledge_chunking.py +118 -0
- keywright_server/knowledge_index.py +233 -0
- keywright_server/knowledge_mcp.py +82 -0
- keywright_server/llm.py +338 -0
- keywright_server/llm_proxy.py +271 -0
- keywright_server/llm_usage.py +99 -0
- keywright_server/mail.py +312 -0
- keywright_server/mcp_oauth.py +422 -0
- keywright_server/mcp_proxy.py +128 -0
- keywright_server/mcp_server.py +1025 -0
- keywright_server/models.py +442 -0
- keywright_server/network_map.py +128 -0
- keywright_server/notify/__init__.py +8 -0
- keywright_server/notify/base.py +156 -0
- keywright_server/notify/homeassistant.py +221 -0
- keywright_server/notify/log.py +30 -0
- keywright_server/notify/manager.py +131 -0
- keywright_server/notify/webhook.py +79 -0
- keywright_server/nudges.py +121 -0
- keywright_server/omada.py +179 -0
- keywright_server/policy.py +265 -0
- keywright_server/policy_preview.py +151 -0
- keywright_server/reconciliation.py +188 -0
- keywright_server/result_inspection.py +293 -0
- keywright_server/semantic.py +451 -0
- keywright_server/semantic_questions.json +120 -0
- keywright_server/settings_store.py +44 -0
- keywright_server/ssh.py +290 -0
- keywright_server/tool_access.py +260 -0
- keywright_server/usage.py +121 -0
- keywright_server/web/__init__.py +0 -0
- keywright_server/web/admin.py +1126 -0
- keywright_server/web/app.py +154 -0
- keywright_server/web/auth.py +144 -0
- keywright_server/web/discovery_ui.py +112 -0
- keywright_server/web/friction_ui.py +92 -0
- keywright_server/web/inventory_ui.py +192 -0
- keywright_server/web/knowledge_ui.py +177 -0
- keywright_server/web/mail_ui.py +190 -0
- keywright_server/web/mcp_oauth_ui.py +87 -0
- keywright_server/web/network_map_ui.py +37 -0
- keywright_server/web/oidc.py +231 -0
- keywright_server/web/security.py +77 -0
- keywright_server/web/templates/_activity_row.html +25 -0
- keywright_server/web/templates/_agent_setup.html +50 -0
- keywright_server/web/templates/_collections_style.html +88 -0
- keywright_server/web/templates/_constraints.html +6 -0
- keywright_server/web/templates/_decide.html +39 -0
- keywright_server/web/templates/_device_kind_picker.html +66 -0
- keywright_server/web/templates/_hazards.html +6 -0
- keywright_server/web/templates/_icons.html +19 -0
- keywright_server/web/templates/_infrastructure_nav.html +8 -0
- keywright_server/web/templates/_integration_style.html +30 -0
- keywright_server/web/templates/_inventory_forms.html +36 -0
- keywright_server/web/templates/_inventory_style.html +36 -0
- keywright_server/web/templates/_live.html +10 -0
- keywright_server/web/templates/_mail_nav.html +5 -0
- keywright_server/web/templates/_mail_style.html +10 -0
- keywright_server/web/templates/_network_map_script.html +5 -0
- keywright_server/web/templates/_nudge_card.html +12 -0
- keywright_server/web/templates/_patterns.html +6 -0
- keywright_server/web/templates/_policy_preview_results.html +13 -0
- keywright_server/web/templates/_request_card.html +46 -0
- keywright_server/web/templates/_semantic.html +10 -0
- keywright_server/web/templates/_topology_controller.js +217 -0
- keywright_server/web/templates/_topology_layout.js +174 -0
- keywright_server/web/templates/_topology_render.js +117 -0
- keywright_server/web/templates/access.html +119 -0
- keywright_server/web/templates/agents.html +49 -0
- keywright_server/web/templates/audit.html +6 -0
- keywright_server/web/templates/base.html +198 -0
- keywright_server/web/templates/dashboard.html +226 -0
- keywright_server/web/templates/decision.html +26 -0
- keywright_server/web/templates/friction.html +36 -0
- keywright_server/web/templates/friction_review.html +21 -0
- keywright_server/web/templates/grants.html +61 -0
- keywright_server/web/templates/integration_picker.html +47 -0
- keywright_server/web/templates/inventory.html +21 -0
- keywright_server/web/templates/inventory_asset.html +42 -0
- keywright_server/web/templates/inventory_discovery.html +32 -0
- keywright_server/web/templates/inventory_discovery_review.html +34 -0
- keywright_server/web/templates/inventory_map.html +45 -0
- keywright_server/web/templates/inventory_networks.html +12 -0
- keywright_server/web/templates/inventory_subnet.html +41 -0
- keywright_server/web/templates/inventory_topology.html +22 -0
- keywright_server/web/templates/knowledge.html +25 -0
- keywright_server/web/templates/knowledge_document.html +7 -0
- keywright_server/web/templates/knowledge_proposal.html +7 -0
- keywright_server/web/templates/login.html +9 -0
- keywright_server/web/templates/mail.html +45 -0
- keywright_server/web/templates/mail_rules.html +24 -0
- keywright_server/web/templates/mail_settings.html +19 -0
- keywright_server/web/templates/mcp_oauth_continue.html +14 -0
- keywright_server/web/templates/policies.html +30 -0
- keywright_server/web/templates/request_detail.html +27 -0
- keywright_server/web/templates/requests.html +9 -0
- keywright_server/web/templates/resource_form.html +198 -0
- keywright_server/web/templates/resources.html +76 -0
- keywright_server/web/templates/settings.html +53 -0
- keywright_server/web/templates/setup.html +8 -0
- keywright_server/web/templates/usage.html +84 -0
- keywright_server/web/usage_ui.py +41 -0
- keywright_server-0.0.1.dist-info/METADATA +152 -0
- keywright_server-0.0.1.dist-info/RECORD +127 -0
- keywright_server-0.0.1.dist-info/WHEEL +4 -0
- keywright_server-0.0.1.dist-info/entry_points.txt +2 -0
- keywright_server-0.0.1.dist-info/licenses/LICENSE +202 -0
- keywright_server-0.0.1.dist-info/licenses/NOTICE +5 -0
- keywright_server-0.0.1.dist-info/licenses/THIRD_PARTY_NOTICES +55 -0
|
@@ -0,0 +1,433 @@
|
|
|
1
|
+
"""Request lifecycle: create -> notify -> (human decides) -> grant/policy -> waiters wake up."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import contextlib
|
|
7
|
+
import hmac
|
|
8
|
+
import logging
|
|
9
|
+
from datetime import timedelta
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer
|
|
13
|
+
from keywright.hazards import blocks
|
|
14
|
+
from keywright.patterns import overlaps, resource_of
|
|
15
|
+
from sqlalchemy import select, update
|
|
16
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
17
|
+
|
|
18
|
+
from . import actions, audit, events, llm
|
|
19
|
+
from .access import (
|
|
20
|
+
binds_exactly,
|
|
21
|
+
hazard_level,
|
|
22
|
+
operation_constraints,
|
|
23
|
+
operation_matches,
|
|
24
|
+
operations,
|
|
25
|
+
path_scoped,
|
|
26
|
+
target_of,
|
|
27
|
+
)
|
|
28
|
+
from .config import settings
|
|
29
|
+
from .models import AccessRequest, Agent, Grant, Policy, Resource, now
|
|
30
|
+
|
|
31
|
+
log = logging.getLogger(__name__)
|
|
32
|
+
|
|
33
|
+
TTL_PRESETS = {"ttl15": 900, "ttl30": 1800, "ttl": 3600}
|
|
34
|
+
SCOPES = ("once", "session", "ttl15", "path15", "ttl30", "ttl", "always", "deny", "deny_always")
|
|
35
|
+
PHONE_SCOPES = ("once", "session", "ttl15", "path15", "ttl30", "ttl", "deny")
|
|
36
|
+
SCOPE_LABELS = {"once": "Allow once", "session": "This session", "ttl15": "15 minutes", "path15": "This path, 15 min",
|
|
37
|
+
"ttl30": "30 minutes", "ttl": "1 hour", "always": "Always allow",
|
|
38
|
+
"deny": "Deny", "deny_always": "Always deny"}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def decision_label(req: AccessRequest) -> str:
|
|
42
|
+
if req.decision_scope == "ttl" and req.decision_options:
|
|
43
|
+
minutes = req.decision_options["ttl_s"] // 60
|
|
44
|
+
return "1 hour" if minutes == 60 else f"{minutes} minute{'' if minutes == 1 else 's'}"
|
|
45
|
+
return SCOPE_LABELS.get(req.decision_scope, req.decision_scope or "Pending")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
_waiters: dict[str, asyncio.Event] = {}
|
|
49
|
+
_waiter_counts: dict[str, int] = {}
|
|
50
|
+
_serializer: URLSafeTimedSerializer | None = None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _ser() -> URLSafeTimedSerializer:
|
|
54
|
+
global _serializer
|
|
55
|
+
if _serializer is None:
|
|
56
|
+
_serializer = URLSafeTimedSerializer(settings.secret_key(), salt="decision")
|
|
57
|
+
return _serializer
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def decision_token(request_id: str, scope: str) -> str:
|
|
61
|
+
if scope not in PHONE_SCOPES:
|
|
62
|
+
raise ValueError("Permanent approval requires the admin UI")
|
|
63
|
+
return _ser().dumps({"r": request_id, "s": scope})
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def parse_decision_token(token: str, max_age: int | None = None) -> tuple[str, str] | None:
|
|
67
|
+
try:
|
|
68
|
+
d = _ser().loads(token, max_age=max_age or settings.request_timeout_s)
|
|
69
|
+
except (BadSignature, SignatureExpired):
|
|
70
|
+
return None
|
|
71
|
+
if not isinstance(d, dict) or not isinstance(d.get("r"), str) or d.get("s") not in PHONE_SCOPES:
|
|
72
|
+
return None
|
|
73
|
+
return d["r"], d["s"]
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def notification_action(request_id: str, scope: str) -> str:
|
|
77
|
+
"""Compact authenticated phone action; the 128-bit MAC cannot be derived from a request ID."""
|
|
78
|
+
if scope not in PHONE_SCOPES:
|
|
79
|
+
raise ValueError("Permanent approval requires the admin UI")
|
|
80
|
+
return _action("KW", request_id, scope)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _action(prefix: str, request_id: str, scope: str) -> str:
|
|
84
|
+
return actions.sign(f"{prefix}_{scope.upper()}_{request_id}")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def parse_notification_action(action: str) -> tuple[str, str] | None:
|
|
88
|
+
"""Accepts KW_ actions and, for one release, GK_ actions from notifications sent before the rename."""
|
|
89
|
+
try:
|
|
90
|
+
body, _ = action.rsplit(".", 1)
|
|
91
|
+
prefix, scope, rid = body.split("_", 2)
|
|
92
|
+
if prefix not in ("KW", "GK") or scope.lower() not in PHONE_SCOPES:
|
|
93
|
+
return None
|
|
94
|
+
if hmac.compare_digest(action, _action(prefix, rid, scope.lower())):
|
|
95
|
+
return rid, scope.lower()
|
|
96
|
+
except (ValueError, TypeError):
|
|
97
|
+
pass
|
|
98
|
+
return None
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
NO_SESSION_NOTE = ("No session id on this connection, so 'This session' cannot be granted. Send an "
|
|
102
|
+
"X-Keywright-Session header (one value per process) to enable it; until then the human "
|
|
103
|
+
"can choose Allow once or timed access (15 minutes, 30 minutes, or 1 hour).")
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
HAZARD_SCOPES = ("once", "deny", "deny_always")
|
|
107
|
+
# An LLM lease is a virtual key with a lifetime and a spend ceiling. "Once" has no lifetime to give
|
|
108
|
+
# it and "always" would be a key nobody minted; both wait for the LLM proxy, which meters calls.
|
|
109
|
+
LLM_SCOPES = ("session", "ttl15", "ttl30", "ttl", "deny", "deny_always")
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def llm_only(patterns: list[str]) -> bool:
|
|
113
|
+
return bool(patterns) and all(p.split("/", 1)[0] == "llm" for p in patterns)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def offered_scopes(req: AccessRequest, scopes: tuple[str, ...] = PHONE_SCOPES) -> tuple[str, ...]:
|
|
117
|
+
"""Scopes a human can pick for this request. A request made on a connection with no session
|
|
118
|
+
id cannot be granted 'this session': there is nothing to bind the grant to, and an agent-wide
|
|
119
|
+
grant would outlive the conversation that asked for it. A request flagged destructive can only
|
|
120
|
+
be allowed once: nothing timed, nothing standing. An LLM lease is always timed."""
|
|
121
|
+
if blocks(hazard_level(req.detail)):
|
|
122
|
+
scopes = tuple(s for s in scopes if s in HAZARD_SCOPES)
|
|
123
|
+
if llm_only(list(req.patterns)):
|
|
124
|
+
scopes = tuple(s for s in scopes if s in LLM_SCOPES)
|
|
125
|
+
scopes = tuple(s for s in scopes if s != "path15" or path_scoped(req.detail))
|
|
126
|
+
return tuple(s for s in scopes if s != "session" or req.session_id)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def decision_links(request_id: str, scopes: tuple[str, ...] = PHONE_SCOPES) -> dict[str, str]:
|
|
130
|
+
base = public_url()
|
|
131
|
+
return {s: f"{base}/d/{decision_token(request_id, s)}" for s in scopes}
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def public_url() -> str:
|
|
135
|
+
from .settings_store import cached_public_url
|
|
136
|
+
|
|
137
|
+
return (cached_public_url() or settings.public_url or f"http://localhost:{settings.port}").rstrip("/")
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
async def find_pending(db: AsyncSession, agent: Agent, patterns: list[str],
|
|
141
|
+
session_id: str | None = None, detail: dict | None = None) -> AccessRequest | None:
|
|
142
|
+
"""Reuse an identical pending request instead of spamming the phone."""
|
|
143
|
+
q = select(AccessRequest).where(
|
|
144
|
+
AccessRequest.agent_id == agent.id, AccessRequest.status == "pending",
|
|
145
|
+
AccessRequest.expires_at > now(), AccessRequest.session_id == session_id
|
|
146
|
+
).order_by(AccessRequest.created_at.desc())
|
|
147
|
+
for r in (await db.execute(q)).scalars().all():
|
|
148
|
+
if list(r.patterns) == list(patterns) and r.detail == (detail or {}):
|
|
149
|
+
return r
|
|
150
|
+
return None
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
async def recently_denied(db: AsyncSession, agent: Agent, patterns: list[str], within_s: int = 600) -> AccessRequest | None:
|
|
154
|
+
"""The cooldown keys on patterns alone: a reworded command or a switch to access_request must
|
|
155
|
+
not put a denied operation straight back in front of the human."""
|
|
156
|
+
cutoff = now() - timedelta(seconds=within_s)
|
|
157
|
+
q = select(AccessRequest).where(
|
|
158
|
+
AccessRequest.agent_id == agent.id, AccessRequest.status == "denied", AccessRequest.decided_at >= cutoff
|
|
159
|
+
)
|
|
160
|
+
for r in (await db.execute(q)).scalars().all():
|
|
161
|
+
if set(r.patterns) >= set(patterns):
|
|
162
|
+
return r
|
|
163
|
+
return None
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
async def create(
|
|
167
|
+
db: AsyncSession,
|
|
168
|
+
agent: Agent,
|
|
169
|
+
session_id: str | None,
|
|
170
|
+
patterns: list[str],
|
|
171
|
+
reason: str,
|
|
172
|
+
detail: dict[str, Any] | None = None,
|
|
173
|
+
*,
|
|
174
|
+
semantic: dict[str, Any] | None = None,
|
|
175
|
+
notify: bool = True,
|
|
176
|
+
) -> AccessRequest:
|
|
177
|
+
"""`notify=False` is for a request that is decided in the same breath (Jev trust mode): the
|
|
178
|
+
phone would otherwise get a prompt and its cancellation a moment apart."""
|
|
179
|
+
req = AccessRequest(
|
|
180
|
+
agent_id=agent.id,
|
|
181
|
+
session_id=session_id,
|
|
182
|
+
patterns=list(patterns),
|
|
183
|
+
reason=reason[:2000],
|
|
184
|
+
detail=detail or {},
|
|
185
|
+
semantic=semantic,
|
|
186
|
+
expires_at=now() + timedelta(seconds=settings.request_timeout_s),
|
|
187
|
+
)
|
|
188
|
+
db.add(req)
|
|
189
|
+
await db.commit()
|
|
190
|
+
await db.refresh(req)
|
|
191
|
+
await audit.record(db, tool="access.request", decision="pending", agent=agent, session_id=session_id,
|
|
192
|
+
patterns=patterns, detail={"request_id": req.id, "reason": reason})
|
|
193
|
+
from .notify.manager import manager
|
|
194
|
+
|
|
195
|
+
if notify:
|
|
196
|
+
await manager.notify_request(req, agent)
|
|
197
|
+
events.publish("request.created", id=req.id)
|
|
198
|
+
return req
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
async def wait(request_id: str, timeout_s: float) -> None:
|
|
202
|
+
# Events accelerate local decisions; polling also observes another worker's decisions.
|
|
203
|
+
# No events are allocated for requests nobody is waiting on.
|
|
204
|
+
ev = _waiters.setdefault(request_id, asyncio.Event())
|
|
205
|
+
_waiter_counts[request_id] = _waiter_counts.get(request_id, 0) + 1
|
|
206
|
+
deadline = asyncio.get_running_loop().time() + max(0, timeout_s)
|
|
207
|
+
from .db import session
|
|
208
|
+
|
|
209
|
+
try:
|
|
210
|
+
while True:
|
|
211
|
+
async with session() as db:
|
|
212
|
+
req = await db.get(AccessRequest, request_id)
|
|
213
|
+
if req is None or req.status != "pending" or (req.expires_at and req.expires_at <= now()):
|
|
214
|
+
return
|
|
215
|
+
remaining = deadline - asyncio.get_running_loop().time()
|
|
216
|
+
if remaining <= 0:
|
|
217
|
+
return
|
|
218
|
+
try:
|
|
219
|
+
await asyncio.wait_for(ev.wait(), timeout=min(remaining, 1))
|
|
220
|
+
return
|
|
221
|
+
except TimeoutError:
|
|
222
|
+
pass
|
|
223
|
+
finally:
|
|
224
|
+
_waiter_counts[request_id] -= 1
|
|
225
|
+
if not _waiter_counts[request_id]:
|
|
226
|
+
_waiter_counts.pop(request_id)
|
|
227
|
+
_waiters.pop(request_id, None)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
async def decide(
|
|
231
|
+
db: AsyncSession,
|
|
232
|
+
request_id: str,
|
|
233
|
+
scope: str,
|
|
234
|
+
*,
|
|
235
|
+
via: str,
|
|
236
|
+
deny_reason: str = "",
|
|
237
|
+
ttl_s: int = 3600,
|
|
238
|
+
extent: str | None = None,
|
|
239
|
+
) -> AccessRequest:
|
|
240
|
+
if scope not in SCOPES:
|
|
241
|
+
raise ValueError(f"unknown scope {scope!r}")
|
|
242
|
+
# "This path, 15 min": the listed pattern (host and path) for 15 minutes, whatever the contents.
|
|
243
|
+
# The one phone button that broadens beyond the exact operation, offered only where the
|
|
244
|
+
# pattern is as narrow as a file path.
|
|
245
|
+
path_grant = scope == "path15"
|
|
246
|
+
if path_grant:
|
|
247
|
+
scope = "ttl15"
|
|
248
|
+
if scope in ("always", "deny_always") and via != "ui":
|
|
249
|
+
raise ValueError("Permanent approval requires the admin UI")
|
|
250
|
+
if extent not in (None, "exact", "patterns"):
|
|
251
|
+
raise ValueError("Choose exact operation or permission patterns")
|
|
252
|
+
if via != "ui" and extent is not None:
|
|
253
|
+
raise ValueError("Changing operation scope requires the admin UI")
|
|
254
|
+
if scope in TTL_PRESETS:
|
|
255
|
+
ttl_s = TTL_PRESETS[scope] if scope != "ttl" else ttl_s
|
|
256
|
+
if type(ttl_s) is not int or not 60 <= ttl_s <= 3600:
|
|
257
|
+
raise ValueError("Temporary access must last between 1 minute and 1 hour")
|
|
258
|
+
scope = "ttl"
|
|
259
|
+
req = await db.get(AccessRequest, request_id)
|
|
260
|
+
if req is None:
|
|
261
|
+
raise LookupError("no such request")
|
|
262
|
+
if req.status != "pending":
|
|
263
|
+
return req
|
|
264
|
+
if path_grant:
|
|
265
|
+
if not path_scoped(req.detail):
|
|
266
|
+
raise ValueError("Path access is only offered for a single file write")
|
|
267
|
+
extent = "patterns"
|
|
268
|
+
extent = extent or ("exact" if binds_exactly(req.detail) else "patterns")
|
|
269
|
+
if extent == "exact" and not binds_exactly(req.detail):
|
|
270
|
+
raise ValueError("This request specifies permission patterns, not an exact operation")
|
|
271
|
+
if blocks(hazard_level(req.detail)):
|
|
272
|
+
if scope not in HAZARD_SCOPES:
|
|
273
|
+
raise ValueError("Flagged as destructive: it can be allowed once or denied, never for a duration")
|
|
274
|
+
if extent != "exact":
|
|
275
|
+
raise ValueError("Flagged as destructive: approval binds to the exact operation shown")
|
|
276
|
+
# One (patterns, constraints) pair per operation; a pattern request is one pair with none.
|
|
277
|
+
bound = [(pats if pats is not None else list(req.patterns), operation_constraints(op, extent), op)
|
|
278
|
+
for pats, op in operations(req.detail)] or [(list(req.patterns), None, None)]
|
|
279
|
+
if scope == "session" and not req.session_id:
|
|
280
|
+
raise ValueError("This request has no session id; choose Allow once or 1 hour instead")
|
|
281
|
+
if scope not in LLM_SCOPES and any(p.split("/", 1)[0] == "llm" for p in req.patterns):
|
|
282
|
+
raise ValueError("LLM access is a timed lease: choose this session, 15 or 30 minutes, or 1 hour")
|
|
283
|
+
if req.expires_at and req.expires_at <= now():
|
|
284
|
+
await db.execute(update(AccessRequest).where(
|
|
285
|
+
AccessRequest.id == request_id, AccessRequest.status == "pending",
|
|
286
|
+
).values(status="expired").execution_options(synchronize_session=False))
|
|
287
|
+
await db.commit()
|
|
288
|
+
await db.refresh(req)
|
|
289
|
+
_wake(req.id)
|
|
290
|
+
events.publish("request.decided", id=req.id, status=req.status)
|
|
291
|
+
return req
|
|
292
|
+
|
|
293
|
+
if scope not in ("deny", "deny_always"):
|
|
294
|
+
identity = await db.get(Agent, req.agent_id, populate_existing=True)
|
|
295
|
+
if identity is None or identity.disabled:
|
|
296
|
+
raise ValueError("Agent is disabled or deleted")
|
|
297
|
+
resources = {r.path_prefix: r for r in (await db.execute(select(Resource))).scalars().all()}
|
|
298
|
+
policies = (await db.execute(select(Policy).where(
|
|
299
|
+
(Policy.agent_id == req.agent_id) | Policy.agent_id.is_(None)))).scalars().all()
|
|
300
|
+
for pats, _, op in bound:
|
|
301
|
+
for pattern in pats:
|
|
302
|
+
rp = resource_of(pattern)
|
|
303
|
+
res = resources.get("/".join(rp)) if rp else None
|
|
304
|
+
if res is None or (res.default_mode == "locked" and scope != "always"):
|
|
305
|
+
raise ValueError("Resource is missing or locked; update its rules in the admin UI")
|
|
306
|
+
if any(p.effect == "deny" and overlaps(p.pattern, pattern)
|
|
307
|
+
and operation_matches(p.constraints, op or target_of(res))
|
|
308
|
+
for p in policies):
|
|
309
|
+
raise ValueError("A standing deny rule now covers this request")
|
|
310
|
+
decided_at = now()
|
|
311
|
+
changed = await db.execute(update(AccessRequest).where(
|
|
312
|
+
AccessRequest.id == request_id, AccessRequest.status == "pending",
|
|
313
|
+
(AccessRequest.expires_at.is_(None)) | (AccessRequest.expires_at > decided_at),
|
|
314
|
+
).values(status="denied" if scope in ("deny", "deny_always") else "approved", decided_at=decided_at,
|
|
315
|
+
decision_scope=scope, decided_via=via,
|
|
316
|
+
decision_options={"extent": extent, "ttl_s": ttl_s if scope == "ttl" else None}
|
|
317
|
+
).execution_options(synchronize_session=False))
|
|
318
|
+
if changed.rowcount != 1:
|
|
319
|
+
await db.rollback()
|
|
320
|
+
await db.refresh(req)
|
|
321
|
+
return req
|
|
322
|
+
await db.refresh(req)
|
|
323
|
+
if scope in ("deny", "deny_always"):
|
|
324
|
+
req.status = "denied"
|
|
325
|
+
req.deny_reason = deny_reason[:500]
|
|
326
|
+
if scope == "deny_always":
|
|
327
|
+
for pats, constraints, _ in bound:
|
|
328
|
+
for p in pats:
|
|
329
|
+
db.add(Policy(agent_id=req.agent_id, pattern=p, effect="deny", constraints=constraints,
|
|
330
|
+
request_id=req.id,
|
|
331
|
+
created_by=via, note=f"from request {req.id}: {req.reason[:120]}"))
|
|
332
|
+
else:
|
|
333
|
+
req.status = "approved"
|
|
334
|
+
new_grants: list[Grant] = []
|
|
335
|
+
for pats, constraints, _ in bound:
|
|
336
|
+
for p in pats:
|
|
337
|
+
if scope == "always":
|
|
338
|
+
db.add(Policy(agent_id=req.agent_id, pattern=p, effect="allow", constraints=constraints,
|
|
339
|
+
request_id=req.id, created_by=via,
|
|
340
|
+
note=f"from request {req.id}: {req.reason[:120]}"))
|
|
341
|
+
continue
|
|
342
|
+
g = Grant(
|
|
343
|
+
agent_id=req.agent_id,
|
|
344
|
+
pattern=p,
|
|
345
|
+
constraints=constraints,
|
|
346
|
+
scope=scope,
|
|
347
|
+
session_id=req.session_id if scope == "session" else None,
|
|
348
|
+
expires_at=(decided_at + timedelta(seconds=ttl_s)) if scope == "ttl"
|
|
349
|
+
else (decided_at + timedelta(seconds=settings.session_grant_ttl_s)) if scope == "session"
|
|
350
|
+
else None,
|
|
351
|
+
uses_remaining=1 if scope == "once" else None,
|
|
352
|
+
request_id=req.id,
|
|
353
|
+
)
|
|
354
|
+
db.add(g)
|
|
355
|
+
new_grants.append(g)
|
|
356
|
+
await db.flush()
|
|
357
|
+
# The lease and its key are one decision: a gateway that cannot mint (unreachable, or no
|
|
358
|
+
# database to enforce budgets against) leaves the request pending for the human to see.
|
|
359
|
+
try:
|
|
360
|
+
keys = await llm.mint_for_grants(db, identity, new_grants, request_id=req.id, reason=req.reason)
|
|
361
|
+
except llm.GatewayError as e:
|
|
362
|
+
await db.rollback() # the request stays pending; whoever is waiting keeps waiting
|
|
363
|
+
raise ValueError(f"Not approved. {e}") from e
|
|
364
|
+
for k in keys:
|
|
365
|
+
await audit.record(db, tool="llm.key", decision="minted", agent=identity, session_id=req.session_id,
|
|
366
|
+
patterns=[g.pattern for g in new_grants if g.id in k.grant_ids],
|
|
367
|
+
detail={"request_id": req.id, "key_alias": k.key_alias, "models": k.models,
|
|
368
|
+
"limits": k.limits, "expires_at": k.expires_at.isoformat()}, commit=False)
|
|
369
|
+
await db.commit()
|
|
370
|
+
await audit.record(db, tool="access.decide", decision=req.status, agent=req.agent, session_id=req.session_id,
|
|
371
|
+
patterns=list(req.patterns), detail={"request_id": req.id, "scope": scope, "via": via,
|
|
372
|
+
"deny_reason": deny_reason,
|
|
373
|
+
**req.decision_options})
|
|
374
|
+
_wake(req.id)
|
|
375
|
+
from .notify.manager import manager
|
|
376
|
+
|
|
377
|
+
await manager.request_decided(req)
|
|
378
|
+
events.publish("request.decided", id=req.id, status=req.status, via=via, label=decision_label(req))
|
|
379
|
+
return req
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def _wake(request_id: str) -> None:
|
|
383
|
+
ev = _waiters.pop(request_id, None)
|
|
384
|
+
if ev:
|
|
385
|
+
ev.set()
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
async def expire_stale(db: AsyncSession) -> int:
|
|
389
|
+
expired = list((await db.execute(update(AccessRequest).where(
|
|
390
|
+
AccessRequest.status == "pending", AccessRequest.expires_at <= now(),
|
|
391
|
+
).values(status="expired").returning(AccessRequest.id))).scalars())
|
|
392
|
+
await db.commit()
|
|
393
|
+
if expired:
|
|
394
|
+
# Take the dead buttons off the phone: an expired request can no longer be decided.
|
|
395
|
+
from .notify.manager import manager
|
|
396
|
+
|
|
397
|
+
for req in (await db.execute(select(AccessRequest).where(AccessRequest.id.in_(expired)))).scalars():
|
|
398
|
+
with contextlib.suppress(Exception):
|
|
399
|
+
await manager.request_decided(req)
|
|
400
|
+
for rid in expired:
|
|
401
|
+
_wake(rid)
|
|
402
|
+
events.publish("request.decided", id=rid, status="expired")
|
|
403
|
+
return len(expired)
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
async def revoke_grant(db: AsyncSession, grant_id: str, via: str = "ui") -> Grant | None:
|
|
407
|
+
g = await db.get(Grant, grant_id)
|
|
408
|
+
if g and g.revoked_at is None:
|
|
409
|
+
g.revoked_at = now()
|
|
410
|
+
await db.commit()
|
|
411
|
+
await audit.record(db, tool="grant.revoke", decision="revoked", patterns=[g.pattern],
|
|
412
|
+
detail={"grant_id": g.id, "agent_id": g.agent_id, "via": via})
|
|
413
|
+
if g.pattern.startswith("llm/"):
|
|
414
|
+
await llm.revoke_for_grants(db, [g.id], via=via)
|
|
415
|
+
await db.commit()
|
|
416
|
+
return g
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
async def revoke_request_grants(db: AsyncSession, request_id: str) -> int:
|
|
420
|
+
"""Revoke all remaining grants from one approval in a single transaction."""
|
|
421
|
+
grants = (await db.execute(select(Grant).where(
|
|
422
|
+
Grant.request_id == request_id, Grant.revoked_at.is_(None)))).scalars().all()
|
|
423
|
+
at = now()
|
|
424
|
+
for g in grants:
|
|
425
|
+
g.revoked_at = at
|
|
426
|
+
await audit.record(db, tool="grant.revoke", decision="revoked", patterns=[g.pattern],
|
|
427
|
+
detail={"grant_id": g.id, "agent_id": g.agent_id,
|
|
428
|
+
"request_id": request_id, "via": "ui"}, commit=False)
|
|
429
|
+
await db.commit()
|
|
430
|
+
if any(g.pattern.startswith("llm/") for g in grants):
|
|
431
|
+
await llm.revoke_for_grants(db, [g.id for g in grants], via="ui")
|
|
432
|
+
await db.commit()
|
|
433
|
+
return len(grants)
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from contextvars import ContextVar
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
# Set per MCP call from the User-Agent header when it names a Keywright client; recorded on every
|
|
7
|
+
# audit row of that call. Kept out of the operation dict so exact-bound grants never see it.
|
|
8
|
+
client_agent: ContextVar[str | None] = ContextVar("client_agent", default=None)
|
|
9
|
+
|
|
10
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
11
|
+
|
|
12
|
+
from .models import Agent, AuditEvent
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
async def record(
|
|
16
|
+
db: AsyncSession,
|
|
17
|
+
*,
|
|
18
|
+
tool: str,
|
|
19
|
+
decision: str,
|
|
20
|
+
agent: Agent | None = None,
|
|
21
|
+
session_id: str | None = None,
|
|
22
|
+
patterns: list[str] | None = None,
|
|
23
|
+
detail: dict[str, Any] | None = None,
|
|
24
|
+
exit_code: int | None = None,
|
|
25
|
+
duration_ms: int | None = None,
|
|
26
|
+
commit: bool = True,
|
|
27
|
+
) -> AuditEvent:
|
|
28
|
+
ev = AuditEvent(
|
|
29
|
+
agent_id=agent.id if agent else None,
|
|
30
|
+
agent_name=agent.name if agent else "",
|
|
31
|
+
session_id=session_id,
|
|
32
|
+
tool=tool,
|
|
33
|
+
patterns=list(patterns or []),
|
|
34
|
+
decision=decision,
|
|
35
|
+
detail=_scrub({**(detail or {}), **({"client": client_agent.get()} if client_agent.get() else {})}),
|
|
36
|
+
exit_code=exit_code,
|
|
37
|
+
duration_ms=duration_ms,
|
|
38
|
+
)
|
|
39
|
+
db.add(ev)
|
|
40
|
+
if commit:
|
|
41
|
+
await db.commit()
|
|
42
|
+
return ev
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _scrub(value: Any) -> Any:
|
|
46
|
+
"""Redact nested credential fields before persistence, notifications, or display."""
|
|
47
|
+
if isinstance(value, dict):
|
|
48
|
+
return {
|
|
49
|
+
k: "***" if any(s in k.lower() for s in (
|
|
50
|
+
"token", "secret", "password", "authorization", "api_key", "apikey", "private_key", "credential",
|
|
51
|
+
)) else _scrub(v)
|
|
52
|
+
for k, v in value.items()
|
|
53
|
+
}
|
|
54
|
+
if isinstance(value, list):
|
|
55
|
+
return [_scrub(item) for item in value]
|
|
56
|
+
if isinstance(value, str) and len(value) > 4000:
|
|
57
|
+
return value[:4000] + f"... [{len(value) - 4000} more]"
|
|
58
|
+
return value
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Named sets of patterns an MCP resource owner defines, e.g. read-only / control / destructive.
|
|
2
|
+
|
|
3
|
+
Stored in the resource config as {"bundles": {name: ["tools/ha_get_*", ...]}} with entries relative
|
|
4
|
+
to the resource. An agent asks for `mcp/<server>/bundle/<name>` and Keywright expands it before
|
|
5
|
+
evaluation; the human sees the request grouped by bundle. Bundles are a request-time convenience:
|
|
6
|
+
rules and grants are always on the expanded patterns.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
|
|
12
|
+
from keywright.patterns import validate
|
|
13
|
+
|
|
14
|
+
NAME = re.compile(r"^[A-Za-z0-9_.-]{1,40}$")
|
|
15
|
+
MAX_BUNDLES = 32
|
|
16
|
+
MAX_ENTRIES = 64
|
|
17
|
+
|
|
18
|
+
# Verb heuristics for "suggest bundles" from an upstream tool list. Generic, not server-specific.
|
|
19
|
+
_VERBS = {
|
|
20
|
+
"read-only": ("get", "list", "search", "find", "read", "describe", "show", "query", "check", "status", "history",
|
|
21
|
+
"render", "export", "fetch", "lookup"),
|
|
22
|
+
"control": ("call", "run", "execute", "trigger", "manage", "start", "stop", "toggle", "turn", "send", "notify",
|
|
23
|
+
"play", "press", "enable", "disable"),
|
|
24
|
+
"destructive": ("remove", "delete", "restart", "reboot", "reset", "wipe", "purge", "uninstall", "shutdown"),
|
|
25
|
+
"config-write": ("set", "config", "create", "update", "write", "add", "edit", "rename", "install", "upload",
|
|
26
|
+
"import", "apply", "assign", "patch", "put", "save"),
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def parse(text: str, resource_name: str) -> dict[str, list[str]]:
|
|
31
|
+
"""`name: entry, entry` per line -> {name: [entry, ...]}; entries are validated as full paths."""
|
|
32
|
+
out: dict[str, list[str]] = {}
|
|
33
|
+
for raw in text.splitlines():
|
|
34
|
+
line = raw.strip()
|
|
35
|
+
if not line or line.startswith("#"):
|
|
36
|
+
continue
|
|
37
|
+
if ":" not in line:
|
|
38
|
+
raise ValueError(f"expected 'name: pattern, pattern' but got {line!r}")
|
|
39
|
+
name, rest = (s.strip() for s in line.split(":", 1))
|
|
40
|
+
if not NAME.match(name) or name in out:
|
|
41
|
+
raise ValueError(f"bad or duplicate bundle name {name!r}")
|
|
42
|
+
entries: list[str] = []
|
|
43
|
+
for e in (x.strip() for x in rest.replace("\t", ",").split(",")):
|
|
44
|
+
if not e:
|
|
45
|
+
continue
|
|
46
|
+
e = e.strip("/")
|
|
47
|
+
e = e.removeprefix(f"mcp/{resource_name}/")
|
|
48
|
+
full = validate(f"mcp/{resource_name}/{e}")
|
|
49
|
+
rel = full[len(f"mcp/{resource_name}/"):]
|
|
50
|
+
if not rel.startswith("tools/") or len(rel.split("/")) < 2:
|
|
51
|
+
raise ValueError(f"bundle entry {e!r} must be tools/<tool>[/<key>=<value>]")
|
|
52
|
+
if rel not in entries:
|
|
53
|
+
entries.append(rel)
|
|
54
|
+
if not entries:
|
|
55
|
+
raise ValueError(f"bundle {name!r} is empty")
|
|
56
|
+
if len(entries) > MAX_ENTRIES:
|
|
57
|
+
raise ValueError(f"bundle {name!r} has more than {MAX_ENTRIES} entries")
|
|
58
|
+
out[name] = entries
|
|
59
|
+
if len(out) > MAX_BUNDLES:
|
|
60
|
+
raise ValueError(f"more than {MAX_BUNDLES} bundles")
|
|
61
|
+
return out
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def format_text(bundles: dict[str, list[str]] | None) -> str:
|
|
65
|
+
return "\n".join(f"{name}: {', '.join(entries)}" for name, entries in (bundles or {}).items())
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def expand(resource_name: str, bundles: dict[str, list[str]] | None, name: str) -> list[str] | None:
|
|
69
|
+
entries = (bundles or {}).get(name)
|
|
70
|
+
if entries is None:
|
|
71
|
+
return None
|
|
72
|
+
return [f"mcp/{resource_name}/{e}" for e in entries]
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def suggest(tool_names: list[str]) -> dict[str, list[str]]:
|
|
76
|
+
"""Group upstream tools by the verb in their name. A starting point for the owner to edit."""
|
|
77
|
+
out: dict[str, list[str]] = {}
|
|
78
|
+
for tool in sorted(set(tool_names)):
|
|
79
|
+
words = [w for w in re.split(r"[_\-.]+|(?<=[a-z])(?=[A-Z])", tool) if w]
|
|
80
|
+
lowered = [w.lower() for w in words]
|
|
81
|
+
chosen = None
|
|
82
|
+
for bucket, verbs in _VERBS.items():
|
|
83
|
+
if any(w in verbs or any(w.startswith(v) for v in verbs if len(v) >= 4) for w in lowered):
|
|
84
|
+
chosen = bucket
|
|
85
|
+
break
|
|
86
|
+
out.setdefault(chosen or "other", []).append(f"tools/{tool}")
|
|
87
|
+
return {k: v for k, v in out.items() if v}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Process configuration. Everything else (resources, agents, notifiers) lives in the database
|
|
2
|
+
and is managed from the admin UI, so nothing device-specific is ever hard-coded here."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import logging
|
|
7
|
+
import os
|
|
8
|
+
import secrets
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
log = logging.getLogger("keywright.config")
|
|
13
|
+
_warned: set[str] = set()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def env(name: str, default: str | None = None) -> str | None:
|
|
17
|
+
"""KW_<name>, falling back to the pre-rename GK_<name> for one release."""
|
|
18
|
+
value = os.environ.get(f"KW_{name}")
|
|
19
|
+
if value is not None:
|
|
20
|
+
return value
|
|
21
|
+
legacy = os.environ.get(f"GK_{name}")
|
|
22
|
+
if legacy is not None:
|
|
23
|
+
if name not in _warned:
|
|
24
|
+
_warned.add(name)
|
|
25
|
+
log.warning("GK_%s is deprecated; set KW_%s instead", name, name)
|
|
26
|
+
return legacy
|
|
27
|
+
return default
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class Settings:
|
|
32
|
+
data_dir: Path = field(default_factory=lambda: Path(env("DATA_DIR", "./data")).resolve())
|
|
33
|
+
host: str = field(default_factory=lambda: env("HOST", "0.0.0.0"))
|
|
34
|
+
port: int = field(default_factory=lambda: int(env("PORT", "8700")))
|
|
35
|
+
# Used to build links in notifications. Configurable in the UI too (settings.public_url wins).
|
|
36
|
+
public_url: str = field(default_factory=lambda: env("PUBLIC_URL", ""))
|
|
37
|
+
# Reverse proxies whose X-Forwarded-Proto/Host/For headers are trusted (comma-separated IPs,
|
|
38
|
+
# CIDRs or "*"). Empty keeps uvicorn's loopback-only default. Behind TLS-terminating proxies
|
|
39
|
+
# this must name the proxy, or the app sees itself as http:// and rejects browser form posts.
|
|
40
|
+
trusted_proxies: str = field(default_factory=lambda: env("TRUSTED_PROXIES", ""))
|
|
41
|
+
admin_password: str | None = field(default_factory=lambda: env("ADMIN_PASSWORD"))
|
|
42
|
+
auth_mode: str = field(default_factory=lambda: env("AUTH_MODE", "password"))
|
|
43
|
+
oidc_issuer: str = field(default_factory=lambda: env("OIDC_ISSUER", ""))
|
|
44
|
+
oidc_client_id: str = field(default_factory=lambda: env("OIDC_CLIENT_ID", ""))
|
|
45
|
+
oidc_client_secret: str = field(default_factory=lambda: env("OIDC_CLIENT_SECRET", ""))
|
|
46
|
+
oidc_admin_group: str = field(default_factory=lambda: env("OIDC_ADMIN_GROUP", ""))
|
|
47
|
+
oidc_session_ttl_s: int = field(default_factory=lambda: int(env("OIDC_SESSION_TTL", "3600")))
|
|
48
|
+
request_timeout_s: int = field(default_factory=lambda: int(env("REQUEST_TIMEOUT", "900")))
|
|
49
|
+
# How long an unanswered "the agent needs you" nudge stays on the dashboard and the phone.
|
|
50
|
+
nudge_ttl_s: int = field(default_factory=lambda: int(env("NUDGE_TTL", "43200")))
|
|
51
|
+
# Upper bound on "this session" grants; the session itself usually ends sooner.
|
|
52
|
+
session_grant_ttl_s: int = field(default_factory=lambda: int(env("SESSION_GRANT_TTL", "3600")))
|
|
53
|
+
# How long a proxied LLM call waits for the human before answering 403 approval_required.
|
|
54
|
+
llm_wait_s: int = field(default_factory=lambda: int(env("LLM_WAIT", "60")))
|
|
55
|
+
log_level: str = field(default_factory=lambda: env("LOG_LEVEL", "INFO"))
|
|
56
|
+
# TypeSafe key for Jev's second opinion on operations that prompt the human (semantic.py).
|
|
57
|
+
# Absent, the feature is off and nothing leaves the host.
|
|
58
|
+
typesafe_api_key: str | None = field(default_factory=lambda: env("TYPESAFE_API_KEY"))
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def db_path(self) -> Path:
|
|
62
|
+
new, old = self.data_dir / "keywright.db", self.data_dir / "gatekeeper.db"
|
|
63
|
+
if old.exists() and not new.exists():
|
|
64
|
+
for suffix in ("", "-wal", "-shm"):
|
|
65
|
+
if (old.parent / (old.name + suffix)).exists():
|
|
66
|
+
(old.parent / (old.name + suffix)).rename(new.parent / (new.name + suffix))
|
|
67
|
+
log.warning("renamed %s to %s", old, new)
|
|
68
|
+
return new
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def db_url(self) -> str:
|
|
72
|
+
return env("DB_URL") or f"sqlite+aiosqlite:///{self.db_path}"
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def keys_dir(self) -> Path:
|
|
76
|
+
return self.data_dir / "keys"
|
|
77
|
+
|
|
78
|
+
def secret_key(self) -> bytes:
|
|
79
|
+
"""Signing key for sessions and decision tokens. Generated once, kept in the data dir."""
|
|
80
|
+
configured = env("SECRET_KEY")
|
|
81
|
+
if configured:
|
|
82
|
+
return configured.encode()
|
|
83
|
+
p = self.data_dir / "secret.key"
|
|
84
|
+
if p.exists():
|
|
85
|
+
return p.read_bytes().strip()
|
|
86
|
+
self.data_dir.mkdir(parents=True, exist_ok=True)
|
|
87
|
+
key = secrets.token_urlsafe(48).encode()
|
|
88
|
+
p.write_bytes(key)
|
|
89
|
+
p.chmod(0o600)
|
|
90
|
+
return key
|
|
91
|
+
|
|
92
|
+
def ensure_dirs(self) -> None:
|
|
93
|
+
self.data_dir.mkdir(parents=True, exist_ok=True)
|
|
94
|
+
self.keys_dir.mkdir(parents=True, exist_ok=True)
|
|
95
|
+
try:
|
|
96
|
+
os.chmod(self.data_dir, 0o700)
|
|
97
|
+
os.chmod(self.keys_dir, 0o700)
|
|
98
|
+
except OSError:
|
|
99
|
+
pass
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
settings = Settings()
|