primarypath-server 1.0.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.
- app/__init__.py +351 -0
- app/_version.py +8 -0
- app/agents.py +150 -0
- app/alembic.ini +37 -0
- app/auth.py +714 -0
- app/chat.py +122 -0
- app/config.py +258 -0
- app/costgov.py +345 -0
- app/db.py +56 -0
- app/faults.py +26 -0
- app/grants.py +122 -0
- app/idempotency.py +58 -0
- app/jcs.py +117 -0
- app/ledger/__init__.py +41 -0
- app/ledger/fold.py +74 -0
- app/ledger/hashing.py +19 -0
- app/ledger/projection.py +389 -0
- app/ledger/store.py +1042 -0
- app/ledger/verify.py +31 -0
- app/main.py +50 -0
- app/maintenance.py +56 -0
- app/metrics.py +89 -0
- app/migrations/env.py +20 -0
- app/migrations/script.py.mako +23 -0
- app/migrations/versions/0001_baseline.py +19 -0
- app/migrations/versions/0002_events_and_projection.py +78 -0
- app/migrations/versions/0003_slack_messages.py +27 -0
- app/migrations/versions/0004_sessions.py +31 -0
- app/migrations/versions/0005_siem_cursor.py +26 -0
- app/migrations/versions/0006_teams_messages.py +27 -0
- app/migrations/versions/0007_tenant_scoping.py +50 -0
- app/migrations/versions/0008_policy_view.py +39 -0
- app/migrations/versions/0009_api_tokens.py +37 -0
- app/migrations/versions/0010_session_groups.py +23 -0
- app/migrations/versions/0011_budgets_and_evals.py +75 -0
- app/migrations/versions/0012_run_event_ts_index.py +23 -0
- app/migrations/versions/0013_agent_chains.py +102 -0
- app/migrations/versions/0014_dual_control.py +33 -0
- app/migrations/versions/0015_sampled_mode.py +24 -0
- app/migrations/versions/0016_sampling_salt.py +25 -0
- app/migrations/versions/0017_live_tokens_index.py +25 -0
- app/migrations/versions/0018_webhook_outbox.py +47 -0
- app/migrations/versions/0019_usage_view_and_rollout_index.py +68 -0
- app/migrations/versions/0020_policy_metadata_and_lifecycle.py +67 -0
- app/migrations/versions/0021_execution_grants.py +59 -0
- app/migrations/versions/0022_digest_alg_and_executor_instance.py +45 -0
- app/migrations/versions/0023_idempotency_keys.py +38 -0
- app/migrations/versions/0024_approvals_view_keyset_index.py +26 -0
- app/netguard.py +73 -0
- app/obslog.py +105 -0
- app/policy.py +390 -0
- app/ratelimit.py +78 -0
- app/routes/__init__.py +0 -0
- app/routes/admin.py +312 -0
- app/routes/agents.py +111 -0
- app/routes/approvals.py +576 -0
- app/routes/audit.py +213 -0
- app/routes/costgov.py +150 -0
- app/routes/health.py +53 -0
- app/routes/policy.py +72 -0
- app/routes/runs.py +115 -0
- app/routes/slack.py +40 -0
- app/routes/teams.py +29 -0
- app/routes/traces.py +55 -0
- app/schemas.py +248 -0
- app/security.py +203 -0
- app/siem.py +248 -0
- app/signing.py +342 -0
- app/sla.py +234 -0
- app/slack.py +282 -0
- app/teams.py +276 -0
- app/tokens.py +117 -0
- app/traces.py +309 -0
- app/web-dist/assets/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
- app/web-dist/assets/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
- app/web-dist/assets/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
- app/web-dist/assets/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
- app/web-dist/assets/geist-mono-cyrillic-ext-wght-normal-I4S5GZfc.woff2 +0 -0
- app/web-dist/assets/geist-mono-cyrillic-wght-normal-BmXc_FBt.woff2 +0 -0
- app/web-dist/assets/geist-mono-latin-ext-wght-normal-DrnZ1wKl.woff2 +0 -0
- app/web-dist/assets/geist-mono-latin-wght-normal-B_7UjwxQ.woff2 +0 -0
- app/web-dist/assets/geist-mono-symbols2-wght-normal-GZpp1pK2.woff2 +0 -0
- app/web-dist/assets/geist-mono-vietnamese-wght-normal-D8KDMBhC.woff2 +0 -0
- app/web-dist/assets/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
- app/web-dist/assets/index-B_JDe_G2.js +10 -0
- app/web-dist/assets/index-J7J-YtwM.css +1 -0
- app/web-dist/index.html +24 -0
- app/webhooks.py +405 -0
- primarypath_server-1.0.0.dist-info/METADATA +35 -0
- primarypath_server-1.0.0.dist-info/RECORD +94 -0
- primarypath_server-1.0.0.dist-info/WHEEL +5 -0
- primarypath_server-1.0.0.dist-info/entry_points.txt +3 -0
- primarypath_server-1.0.0.dist-info/licenses/LICENSE +202 -0
- primarypath_server-1.0.0.dist-info/top_level.txt +1 -0
app/routes/traces.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from fastapi import APIRouter, Depends, HTTPException, Request
|
|
4
|
+
|
|
5
|
+
from ..auth import (
|
|
6
|
+
activity_scope,
|
|
7
|
+
require_console_reader,
|
|
8
|
+
resolve_tenant,
|
|
9
|
+
scope_units,
|
|
10
|
+
visible_to,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
router = APIRouter(prefix="/api/traces")
|
|
14
|
+
|
|
15
|
+
# a client-supplied limit is clamped: an absurd value would fold the whole
|
|
16
|
+
# table per request
|
|
17
|
+
MAX_LIMIT = 500
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@router.get("", dependencies=[Depends(require_console_reader)])
|
|
21
|
+
async def list_runs(request: Request, status: str | None = None,
|
|
22
|
+
tool: str | None = None, limit: int = 50, cursor: int = 0):
|
|
23
|
+
"""Per-tenant run list with status, gated tools, steps, tokens, cost —
|
|
24
|
+
unit-scoped to the caller (server-enforced, F24)."""
|
|
25
|
+
scope = await activity_scope(request)
|
|
26
|
+
return await request.app.state.traces.run_list(
|
|
27
|
+
tenant=await resolve_tenant(request), status=status, tool=tool,
|
|
28
|
+
limit=max(1, min(limit, MAX_LIMIT)), offset=max(0, cursor),
|
|
29
|
+
units=scope_units(scope),
|
|
30
|
+
agent=scope.get("agent") if scope else None)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@router.get("/cost", dependencies=[Depends(require_console_reader)])
|
|
34
|
+
async def cost(request: Request, since_ms: int | None = None,
|
|
35
|
+
until_ms: int | None = None):
|
|
36
|
+
"""Aggregate tokens/cost for the tenant over a period, by tool and model
|
|
37
|
+
— scoped to the caller's units (spend is volume, volume is information)."""
|
|
38
|
+
scope = await activity_scope(request)
|
|
39
|
+
return await request.app.state.traces.cost_summary(
|
|
40
|
+
tenant=await resolve_tenant(request), since_ms=since_ms,
|
|
41
|
+
until_ms=until_ms, units=scope_units(scope))
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@router.get("/{run_id}", dependencies=[Depends(require_console_reader)])
|
|
45
|
+
async def run_detail(request: Request, run_id: str):
|
|
46
|
+
"""One run's step timeline (reasoning, tool calls, timing), the approval
|
|
47
|
+
and decider, and the per-model cost breakdown — folded from the log."""
|
|
48
|
+
scope = await activity_scope(request)
|
|
49
|
+
trace = await request.app.state.traces.run_trace(
|
|
50
|
+
run_id, tenant=await resolve_tenant(request),
|
|
51
|
+
visible=lambda agent, unit: visible_to(scope, agent, unit))
|
|
52
|
+
if trace is None or not visible_to(scope, trace["agent"], trace["unit"]):
|
|
53
|
+
# outside your units, even existence is need-to-know (F24)
|
|
54
|
+
raise HTTPException(404, f"unknown run {run_id}")
|
|
55
|
+
return trace
|
app/schemas.py
ADDED
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
"""Request models for the HTTP contract (PLAN.md).
|
|
2
|
+
|
|
3
|
+
Every field has an upper bound. An authenticated agent's token buys it
|
|
4
|
+
the right to propose actions, not the right to write unbounded bytes or
|
|
5
|
+
arbitrarily deep JSON into the chain; the limits below are generous for
|
|
6
|
+
real agents and small for a runaway one.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
|
|
12
|
+
from pydantic import BaseModel, Field, field_validator
|
|
13
|
+
|
|
14
|
+
# identifiers: run/call/tool/agent/version/reviewer names
|
|
15
|
+
ID_MAX = 256
|
|
16
|
+
# free text a human reads in the inbox
|
|
17
|
+
REASONING_MAX = 16_384
|
|
18
|
+
REASON_MAX = 4_096
|
|
19
|
+
NOTE_MAX = 16_384
|
|
20
|
+
URL_MAX = 2_048
|
|
21
|
+
# structured payloads: serialized size and nesting
|
|
22
|
+
JSON_MAX_BYTES = 262_144
|
|
23
|
+
JSON_MAX_DEPTH = 16
|
|
24
|
+
EVENT_MAX_BYTES = 65_536
|
|
25
|
+
EVENTS_PER_BATCH_MAX = 500
|
|
26
|
+
LIST_MAX = 1_000
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _depth(v, d: int = 1) -> int:
|
|
30
|
+
if isinstance(v, dict):
|
|
31
|
+
return max([d] + [_depth(x, d + 1) for x in v.values()])
|
|
32
|
+
if isinstance(v, list):
|
|
33
|
+
return max([d] + [_depth(x, d + 1) for x in v])
|
|
34
|
+
return d
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def bounded_json(v, *, max_bytes: int = JSON_MAX_BYTES,
|
|
38
|
+
max_depth: int = JSON_MAX_DEPTH, what: str = "payload"):
|
|
39
|
+
"""Validator: refuse a structure deeper than `max_depth` or larger
|
|
40
|
+
than `max_bytes` when serialized."""
|
|
41
|
+
if _depth(v) > max_depth:
|
|
42
|
+
raise ValueError(f"{what} nests deeper than {max_depth} levels")
|
|
43
|
+
size = len(json.dumps(v, separators=(",", ":"), ensure_ascii=False,
|
|
44
|
+
default=str).encode("utf-8"))
|
|
45
|
+
if size > max_bytes:
|
|
46
|
+
raise ValueError(f"{what} is {size} bytes; the limit is {max_bytes}")
|
|
47
|
+
return v
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# identifiers travel in URL paths (/api/approvals/{run_id}:{call_id},
|
|
51
|
+
# /api/runs/{run_id}/...) and the approval id is the two joined by ":".
|
|
52
|
+
# Only characters that round-trip a path unchanged are accepted, so every
|
|
53
|
+
# approval that can be created can be addressed; "/", "?", "#", "%",
|
|
54
|
+
# whitespace and control characters cannot be, and ":" is the join
|
|
55
|
+
# character, so call_id may not contain it.
|
|
56
|
+
RUN_ID_RE = r"^[A-Za-z0-9][A-Za-z0-9._~@+=:-]*$"
|
|
57
|
+
CALL_ID_RE = r"^[A-Za-z0-9][A-Za-z0-9._~@+=-]*$"
|
|
58
|
+
ID_HINT = ("letters, digits and . _ - ~ @ + = (and : in run_id), starting with "
|
|
59
|
+
"a letter or digit: it must survive a URL path unchanged")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class CreateApproval(BaseModel):
|
|
63
|
+
run_id: str = Field(min_length=1, max_length=ID_MAX, pattern=RUN_ID_RE,
|
|
64
|
+
description=f"URL-safe: {ID_HINT}")
|
|
65
|
+
call_id: str = Field(min_length=1, max_length=ID_MAX, pattern=CALL_ID_RE,
|
|
66
|
+
description=f"URL-safe: {ID_HINT}")
|
|
67
|
+
tool: str = Field(min_length=1, max_length=ID_MAX)
|
|
68
|
+
arguments: dict = Field(default_factory=dict)
|
|
69
|
+
reasoning: str | None = Field(default=None, max_length=REASONING_MAX)
|
|
70
|
+
framework: str | None = Field(default=None, max_length=ID_MAX)
|
|
71
|
+
deadline_ms: int | None = Field(default=None, ge=0)
|
|
72
|
+
callback_url: str | None = Field(default=None, max_length=URL_MAX)
|
|
73
|
+
# the proposal this one replaces. A PENDING one (arguments or context
|
|
74
|
+
# changed) closes as `superseded` in the same transaction. One a
|
|
75
|
+
# reviewer sent back (`changes_requested`) is already closed and keeps
|
|
76
|
+
# that outcome: this is its revision, linked, never rewriting it.
|
|
77
|
+
# Approving the new one never approves the old
|
|
78
|
+
supersedes: str | None = Field(default=None, max_length=2 * ID_MAX + 1)
|
|
79
|
+
|
|
80
|
+
@field_validator("arguments")
|
|
81
|
+
@classmethod
|
|
82
|
+
def _bounded_arguments(cls, v):
|
|
83
|
+
return bounded_json(v, what="arguments")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class Redeem(BaseModel):
|
|
87
|
+
"""Redeem an execution grant (agent plane: the executor's token)."""
|
|
88
|
+
nonce: str = Field(min_length=1, max_length=ID_MAX)
|
|
89
|
+
# the executor's own name for itself ("mcp-gateway@edge-1"): recorded
|
|
90
|
+
# as self-asserted context next to the credential-derived identity
|
|
91
|
+
# the server writes as the attribution
|
|
92
|
+
instance: str = Field(default="", max_length=ID_MAX)
|
|
93
|
+
# 0.2 name for `instance`; accepted for one minor
|
|
94
|
+
executor: str = Field(default="", max_length=ID_MAX)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class Decision(BaseModel):
|
|
98
|
+
approved: bool
|
|
99
|
+
# in SSO mode the verified session identity overrides this field;
|
|
100
|
+
# in open/dev mode it is required (validated in the route)
|
|
101
|
+
reviewer: str = Field(default="", max_length=ID_MAX)
|
|
102
|
+
reason: str = Field(default="", max_length=REASON_MAX)
|
|
103
|
+
# set by a bulk action: the server refuses it for irreversible,
|
|
104
|
+
# dual-control or high/critical-risk actions (decide individually)
|
|
105
|
+
bulk: bool = False
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class ChangesRequest(BaseModel):
|
|
109
|
+
"""POST /api/approvals/{id}/request-changes — send it back, not
|
|
110
|
+
approved and not refused: change it and propose again."""
|
|
111
|
+
reviewer: str = Field(default="", max_length=ID_MAX)
|
|
112
|
+
reason: str = Field(min_length=1, max_length=REASON_MAX)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class Cancel(BaseModel):
|
|
116
|
+
"""POST /api/approvals/{id}/cancel — the agent withdraws its proposal."""
|
|
117
|
+
reason: str = Field(default="", max_length=REASON_MAX)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class Annotation(BaseModel):
|
|
121
|
+
reviewer: str = Field(default="", max_length=ID_MAX)
|
|
122
|
+
note: str = Field(min_length=1, max_length=NOTE_MAX)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
SEVERITIES = ("sev1", "sev2", "sev3", "sev4")
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class Incident(BaseModel):
|
|
129
|
+
"""POST /api/approvals/{id}/incident — a structured outcome: the
|
|
130
|
+
executed action caused or contributed to an incident."""
|
|
131
|
+
reviewer: str = Field(default="", max_length=ID_MAX)
|
|
132
|
+
severity: str = Field(default="sev3", max_length=8)
|
|
133
|
+
summary: str = Field(min_length=1, max_length=NOTE_MAX)
|
|
134
|
+
|
|
135
|
+
@field_validator("severity")
|
|
136
|
+
@classmethod
|
|
137
|
+
def _known_severity(cls, v):
|
|
138
|
+
if v not in SEVERITIES:
|
|
139
|
+
raise ValueError(f"severity must be one of {SEVERITIES}")
|
|
140
|
+
return v
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
class RunEvents(BaseModel):
|
|
144
|
+
events: list[dict] = Field(default_factory=list,
|
|
145
|
+
max_length=EVENTS_PER_BATCH_MAX)
|
|
146
|
+
|
|
147
|
+
@field_validator("events")
|
|
148
|
+
@classmethod
|
|
149
|
+
def _bounded_events(cls, v):
|
|
150
|
+
for i, e in enumerate(v):
|
|
151
|
+
t = e.get("type")
|
|
152
|
+
if t is not None and (not isinstance(t, str) or len(t) > ID_MAX):
|
|
153
|
+
raise ValueError(f"events[{i}].type must be a string of at "
|
|
154
|
+
f"most {ID_MAX} characters")
|
|
155
|
+
bounded_json(e, max_bytes=EVENT_MAX_BYTES, what=f"events[{i}]")
|
|
156
|
+
return v
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class PolicyRule(BaseModel):
|
|
160
|
+
"""PUT /api/policy/{action} — author a per-action rule (F21)."""
|
|
161
|
+
mode: str = Field(max_length=ID_MAX) # validated in the route
|
|
162
|
+
reversible: bool = False
|
|
163
|
+
# None -> derive from mode (assisted gates; shadow/autonomous do not)
|
|
164
|
+
requires_approval: bool | None = None
|
|
165
|
+
# ENFORCED at decision time: a human approver's IdP groups must intersect
|
|
166
|
+
# these (admins bypass). Empty = any approver.
|
|
167
|
+
approver_groups: list[str] = Field(default_factory=list,
|
|
168
|
+
max_length=LIST_MAX)
|
|
169
|
+
# Dual control (four-eyes), ENFORCED at decision time: the first
|
|
170
|
+
# approval endorses (the approval stays pending), a DIFFERENT reviewer
|
|
171
|
+
# must give the second; implies a human gate whatever the mode says.
|
|
172
|
+
require_second_approval: bool = False
|
|
173
|
+
# sampled mode only: 1-in-N proposals routed to a human (deterministic
|
|
174
|
+
# on the approval_id, provable from the chain). Ignored otherwise.
|
|
175
|
+
sample_n: int | None = Field(default=None, ge=2, le=1_000_000)
|
|
176
|
+
# server-authored risk metadata the console shows and bulk approval
|
|
177
|
+
# is constrained on: risk ∈ low/medium/high/critical (None = derived
|
|
178
|
+
# from reversible/dual control), blast_radius = what this action can
|
|
179
|
+
# reach, in the admin's words ("one order", "every customer in EU")
|
|
180
|
+
risk: str | None = Field(default=None, max_length=16)
|
|
181
|
+
blast_radius: str | None = Field(default=None, max_length=ID_MAX)
|
|
182
|
+
|
|
183
|
+
@field_validator("risk")
|
|
184
|
+
@classmethod
|
|
185
|
+
def _known_risk(cls, v):
|
|
186
|
+
if v is not None and v not in ("low", "medium", "high", "critical"):
|
|
187
|
+
raise ValueError("risk must be one of low, medium, high, critical")
|
|
188
|
+
return v
|
|
189
|
+
# SEED values, NOT gate-enforced by the service: exposed via GET /api/policy
|
|
190
|
+
# so a framework bridge (drangue Guardrails, the MCP gate policy) can seed
|
|
191
|
+
# its own per-tool allow/deny. Documented so the audit never records a
|
|
192
|
+
# gate control the service silently ignores.
|
|
193
|
+
allow: list[str] = Field(default_factory=list, max_length=LIST_MAX)
|
|
194
|
+
deny: list[str] = Field(default_factory=list, max_length=LIST_MAX)
|
|
195
|
+
reason: str = Field(default="", max_length=REASON_MAX)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
class Promote(BaseModel):
|
|
199
|
+
"""POST /api/rollout/{action}/promote — move an action's mode (F21)."""
|
|
200
|
+
mode: str = Field(max_length=ID_MAX)
|
|
201
|
+
reason: str = Field(default="", max_length=REASON_MAX)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
class BudgetRule(BaseModel):
|
|
205
|
+
"""PUT /api/budgets/{scope}/{key} — set a budget/quota (F23)."""
|
|
206
|
+
window: str = Field(max_length=ID_MAX) # validated in the route
|
|
207
|
+
on_exceed: str = Field(max_length=ID_MAX) # gate | deny
|
|
208
|
+
max_usd: float | None = Field(default=None, ge=0)
|
|
209
|
+
max_tokens: int | None = Field(default=None, ge=0)
|
|
210
|
+
reason: str = Field(default="", max_length=REASON_MAX)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
class EvalProfile(BaseModel):
|
|
214
|
+
"""POST /api/evals — ingest a drangue eval profile (F23)."""
|
|
215
|
+
agent: str = Field(min_length=1, max_length=ID_MAX)
|
|
216
|
+
version: str = Field(min_length=1, max_length=ID_MAX)
|
|
217
|
+
profile: dict = Field(default_factory=dict)
|
|
218
|
+
scenarios: list = Field(default_factory=list, max_length=LIST_MAX)
|
|
219
|
+
|
|
220
|
+
@field_validator("profile")
|
|
221
|
+
@classmethod
|
|
222
|
+
def _bounded_profile(cls, v):
|
|
223
|
+
return bounded_json(v, what="profile")
|
|
224
|
+
|
|
225
|
+
@field_validator("scenarios")
|
|
226
|
+
@classmethod
|
|
227
|
+
def _bounded_scenarios(cls, v):
|
|
228
|
+
return bounded_json(v, what="scenarios")
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
class GateDecision(BaseModel):
|
|
232
|
+
"""POST /api/gate — record a deploy-gate decision (F23)."""
|
|
233
|
+
agent: str = Field(min_length=1, max_length=ID_MAX)
|
|
234
|
+
version: str = Field(min_length=1, max_length=ID_MAX)
|
|
235
|
+
passed: bool
|
|
236
|
+
baseline_version: str | None = Field(default=None, max_length=ID_MAX)
|
|
237
|
+
blocks: list = Field(default_factory=list, max_length=LIST_MAX)
|
|
238
|
+
reason: str = Field(default="", max_length=REASON_MAX)
|
|
239
|
+
|
|
240
|
+
@field_validator("blocks")
|
|
241
|
+
@classmethod
|
|
242
|
+
def _bounded_blocks(cls, v):
|
|
243
|
+
return bounded_json(v, what="blocks")
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
class GateOverride(BaseModel):
|
|
247
|
+
"""POST /api/gate/{agent}/{version}/override (admin) (F23)."""
|
|
248
|
+
reason: str = Field(min_length=1, max_length=REASON_MAX)
|
app/security.py
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
"""Transport and abuse hardening: the posture a deployment gets without
|
|
2
|
+
having to know to ask for it.
|
|
3
|
+
|
|
4
|
+
- **Secure cookies.** With an https public URL (TLS terminates at the
|
|
5
|
+
ingress) — or PRIMARYPATH_SECURE_COOKIES=1 — session and login-flow
|
|
6
|
+
cookies are `Secure` and carry the `__Host-` prefix (Path=/, no Domain,
|
|
7
|
+
so a sibling host cannot plant one). Plain http keeps the bare names
|
|
8
|
+
for loopback evaluation.
|
|
9
|
+
- **Headers on every response.** An explicit Content-Security-Policy
|
|
10
|
+
(script-src 'self' plus the hash of each inline script the built
|
|
11
|
+
console ships, so nothing else executes), frame-ancestors 'none' and
|
|
12
|
+
X-Frame-Options DENY, nosniff, a strict Referrer-Policy, and HSTS when
|
|
13
|
+
the deployment is secure.
|
|
14
|
+
- **Request-body limit.** Bodies over PRIMARYPATH_MAX_BODY_BYTES are
|
|
15
|
+
refused with 413 before a handler sees them (Content-Length up front;
|
|
16
|
+
chunked bodies are bounded while they stream).
|
|
17
|
+
- **Session-secret strength.** With OIDC on, a secret shorter than
|
|
18
|
+
PRIMARYPATH_SESSION_SECRET_MIN_LENGTH refuses to start.
|
|
19
|
+
"""
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import base64
|
|
23
|
+
import hashlib
|
|
24
|
+
import json
|
|
25
|
+
import re
|
|
26
|
+
from collections.abc import Sequence
|
|
27
|
+
|
|
28
|
+
_INLINE_SCRIPT = re.compile(
|
|
29
|
+
r"<script(?![^>]*\bsrc=)[^>]*>(.*?)</script>", re.DOTALL | re.IGNORECASE)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def is_secure(settings) -> bool:
|
|
33
|
+
if settings.secure_cookies is not None:
|
|
34
|
+
return bool(settings.secure_cookies)
|
|
35
|
+
return str(settings.public_url or "").lower().startswith("https://")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def cookie_name(base: str, settings) -> str:
|
|
39
|
+
"""`__Host-` binds the cookie to this exact host over https only."""
|
|
40
|
+
return f"__Host-{base}" if is_secure(settings) else base
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def cookie_kwargs(settings) -> dict:
|
|
44
|
+
return {"httponly": True, "samesite": "lax", "path": "/",
|
|
45
|
+
"secure": is_secure(settings)}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def inline_script_hashes(html: str) -> list[str]:
|
|
49
|
+
"""CSP 'sha256-...' sources for every inline <script> in the served
|
|
50
|
+
index, so the console's own bootstrap runs and nothing injected does."""
|
|
51
|
+
out = []
|
|
52
|
+
for body in _INLINE_SCRIPT.findall(html):
|
|
53
|
+
digest = hashlib.sha256(body.encode("utf-8")).digest()
|
|
54
|
+
out.append(f"'sha256-{base64.b64encode(digest).decode()}'")
|
|
55
|
+
return out
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def build_csp(settings, script_hashes: Sequence[str] = ()) -> str:
|
|
59
|
+
if settings.csp:
|
|
60
|
+
return settings.csp
|
|
61
|
+
script_src = " ".join(["'self'", *script_hashes])
|
|
62
|
+
return ("default-src 'self'; "
|
|
63
|
+
f"script-src {script_src}; "
|
|
64
|
+
# React sets styles via CSSOM (allowed without unsafe-inline);
|
|
65
|
+
# the bundled CSS is 'self'. 'unsafe-inline' here covers
|
|
66
|
+
# style attributes in markup only — scripts stay strict.
|
|
67
|
+
"style-src 'self' 'unsafe-inline'; "
|
|
68
|
+
"img-src 'self' data:; font-src 'self'; connect-src 'self'; "
|
|
69
|
+
"object-src 'none'; base-uri 'self'; form-action 'self'; "
|
|
70
|
+
"frame-ancestors 'none'")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def security_headers(settings, csp: str) -> list[tuple[bytes, bytes]]:
|
|
74
|
+
headers = [
|
|
75
|
+
(b"content-security-policy", csp.encode()),
|
|
76
|
+
(b"x-frame-options", b"DENY"),
|
|
77
|
+
(b"x-content-type-options", b"nosniff"),
|
|
78
|
+
(b"referrer-policy", b"strict-origin-when-cross-origin"),
|
|
79
|
+
(b"permissions-policy",
|
|
80
|
+
b"camera=(), microphone=(), geolocation=(), payment=()"),
|
|
81
|
+
(b"cross-origin-opener-policy", b"same-origin"),
|
|
82
|
+
]
|
|
83
|
+
if is_secure(settings):
|
|
84
|
+
headers.append((b"strict-transport-security",
|
|
85
|
+
b"max-age=31536000; includeSubDomains"))
|
|
86
|
+
return headers
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def check_session_secret(settings) -> None:
|
|
90
|
+
"""Refuse a guessable cookie-signing secret on the human plane."""
|
|
91
|
+
if not settings.oidc_issuer or not settings.session_secret:
|
|
92
|
+
return
|
|
93
|
+
n = settings.session_secret_min_length
|
|
94
|
+
if len(settings.session_secret) < n:
|
|
95
|
+
raise RuntimeError(
|
|
96
|
+
f"PRIMARYPATH_SESSION_SECRET must be at least {n} characters "
|
|
97
|
+
f"(got {len(settings.session_secret)}); generate one with "
|
|
98
|
+
f"`python -c 'import secrets; print(secrets.token_urlsafe(48))'`")
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
_LOOPBACK_HOSTS = ("127.0.0.1", "localhost", "::1", "[::1]")
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def check_transport_posture(settings) -> None:
|
|
105
|
+
"""The human plane over plain http off loopback would hand out
|
|
106
|
+
non-Secure session cookies: refuse unless the operator explicitly
|
|
107
|
+
turned the secure posture off (PRIMARYPATH_SECURE_COOKIES=0), which is
|
|
108
|
+
visible in /api/admin/config. Loopback (dev with a local IdP) is fine."""
|
|
109
|
+
if not settings.oidc_issuer or settings.secure_cookies is not None:
|
|
110
|
+
return
|
|
111
|
+
url = str(settings.public_url or "")
|
|
112
|
+
host = url.split("://", 1)[-1].split("/", 1)[0].split(":")[0].lower()
|
|
113
|
+
if url.lower().startswith("https://") or host in _LOOPBACK_HOSTS:
|
|
114
|
+
return
|
|
115
|
+
raise RuntimeError(
|
|
116
|
+
f"PRIMARYPATH_PUBLIC_URL is {url!r}: OIDC sessions would be set with "
|
|
117
|
+
"non-Secure cookies over plain http. Set it to the https URL your "
|
|
118
|
+
"TLS terminator serves, or set PRIMARYPATH_SECURE_COOKIES=0 to "
|
|
119
|
+
"accept that explicitly (evaluation only).")
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class SecurityHeadersMiddleware:
|
|
123
|
+
"""Pure ASGI: add the headers to every http response (never overriding
|
|
124
|
+
one a handler set)."""
|
|
125
|
+
|
|
126
|
+
def __init__(self, app, settings, csp: str):
|
|
127
|
+
self.app = app
|
|
128
|
+
self.headers = security_headers(settings, csp)
|
|
129
|
+
|
|
130
|
+
async def __call__(self, scope, receive, send):
|
|
131
|
+
if scope["type"] != "http":
|
|
132
|
+
return await self.app(scope, receive, send)
|
|
133
|
+
|
|
134
|
+
async def send_with_headers(message):
|
|
135
|
+
if message["type"] == "http.response.start":
|
|
136
|
+
present = {k.lower() for k, _ in message.get("headers", [])}
|
|
137
|
+
extra = [(k, v) for k, v in self.headers if k not in present]
|
|
138
|
+
message = {**message,
|
|
139
|
+
"headers": [*message.get("headers", []), *extra]}
|
|
140
|
+
await send(message)
|
|
141
|
+
|
|
142
|
+
await self.app(scope, receive, send_with_headers)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
class BodyLimitMiddleware:
|
|
146
|
+
"""Pure ASGI: 413 for a body over `max_bytes`. A declared
|
|
147
|
+
Content-Length is refused before any byte is read; an undeclared
|
|
148
|
+
(chunked) body is read up to the limit and refused past it, before
|
|
149
|
+
the handler runs."""
|
|
150
|
+
|
|
151
|
+
def __init__(self, app, max_bytes: int):
|
|
152
|
+
self.app = app
|
|
153
|
+
self.max_bytes = max_bytes
|
|
154
|
+
|
|
155
|
+
async def __call__(self, scope, receive, send):
|
|
156
|
+
if scope["type"] != "http" or self.max_bytes <= 0:
|
|
157
|
+
return await self.app(scope, receive, send)
|
|
158
|
+
length = None
|
|
159
|
+
for k, v in scope.get("headers", []):
|
|
160
|
+
if k == b"content-length":
|
|
161
|
+
try:
|
|
162
|
+
length = int(v)
|
|
163
|
+
except ValueError:
|
|
164
|
+
length = None
|
|
165
|
+
if length is not None and length > self.max_bytes:
|
|
166
|
+
return await self._reject(send)
|
|
167
|
+
if length is not None:
|
|
168
|
+
return await self.app(scope, receive, send)
|
|
169
|
+
|
|
170
|
+
# undeclared length: bound it while buffering
|
|
171
|
+
chunks: list[bytes] = []
|
|
172
|
+
total = 0
|
|
173
|
+
while True:
|
|
174
|
+
message = await receive()
|
|
175
|
+
if message["type"] != "http.request":
|
|
176
|
+
break
|
|
177
|
+
body = message.get("body", b"")
|
|
178
|
+
total += len(body)
|
|
179
|
+
if total > self.max_bytes:
|
|
180
|
+
return await self._reject(send)
|
|
181
|
+
chunks.append(body)
|
|
182
|
+
if not message.get("more_body"):
|
|
183
|
+
break
|
|
184
|
+
buffered = b"".join(chunks)
|
|
185
|
+
replayed = False
|
|
186
|
+
|
|
187
|
+
async def replay():
|
|
188
|
+
nonlocal replayed
|
|
189
|
+
if not replayed:
|
|
190
|
+
replayed = True
|
|
191
|
+
return {"type": "http.request", "body": buffered,
|
|
192
|
+
"more_body": False}
|
|
193
|
+
return await receive()
|
|
194
|
+
|
|
195
|
+
await self.app(scope, replay, send)
|
|
196
|
+
|
|
197
|
+
async def _reject(self, send):
|
|
198
|
+
body = json.dumps({"error": "request body too large",
|
|
199
|
+
"max_bytes": self.max_bytes}).encode()
|
|
200
|
+
await send({"type": "http.response.start", "status": 413,
|
|
201
|
+
"headers": [(b"content-type", b"application/json"),
|
|
202
|
+
(b"content-length", str(len(body)).encode())]})
|
|
203
|
+
await send({"type": "http.response.body", "body": body})
|