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.
Files changed (94) hide show
  1. app/__init__.py +351 -0
  2. app/_version.py +8 -0
  3. app/agents.py +150 -0
  4. app/alembic.ini +37 -0
  5. app/auth.py +714 -0
  6. app/chat.py +122 -0
  7. app/config.py +258 -0
  8. app/costgov.py +345 -0
  9. app/db.py +56 -0
  10. app/faults.py +26 -0
  11. app/grants.py +122 -0
  12. app/idempotency.py +58 -0
  13. app/jcs.py +117 -0
  14. app/ledger/__init__.py +41 -0
  15. app/ledger/fold.py +74 -0
  16. app/ledger/hashing.py +19 -0
  17. app/ledger/projection.py +389 -0
  18. app/ledger/store.py +1042 -0
  19. app/ledger/verify.py +31 -0
  20. app/main.py +50 -0
  21. app/maintenance.py +56 -0
  22. app/metrics.py +89 -0
  23. app/migrations/env.py +20 -0
  24. app/migrations/script.py.mako +23 -0
  25. app/migrations/versions/0001_baseline.py +19 -0
  26. app/migrations/versions/0002_events_and_projection.py +78 -0
  27. app/migrations/versions/0003_slack_messages.py +27 -0
  28. app/migrations/versions/0004_sessions.py +31 -0
  29. app/migrations/versions/0005_siem_cursor.py +26 -0
  30. app/migrations/versions/0006_teams_messages.py +27 -0
  31. app/migrations/versions/0007_tenant_scoping.py +50 -0
  32. app/migrations/versions/0008_policy_view.py +39 -0
  33. app/migrations/versions/0009_api_tokens.py +37 -0
  34. app/migrations/versions/0010_session_groups.py +23 -0
  35. app/migrations/versions/0011_budgets_and_evals.py +75 -0
  36. app/migrations/versions/0012_run_event_ts_index.py +23 -0
  37. app/migrations/versions/0013_agent_chains.py +102 -0
  38. app/migrations/versions/0014_dual_control.py +33 -0
  39. app/migrations/versions/0015_sampled_mode.py +24 -0
  40. app/migrations/versions/0016_sampling_salt.py +25 -0
  41. app/migrations/versions/0017_live_tokens_index.py +25 -0
  42. app/migrations/versions/0018_webhook_outbox.py +47 -0
  43. app/migrations/versions/0019_usage_view_and_rollout_index.py +68 -0
  44. app/migrations/versions/0020_policy_metadata_and_lifecycle.py +67 -0
  45. app/migrations/versions/0021_execution_grants.py +59 -0
  46. app/migrations/versions/0022_digest_alg_and_executor_instance.py +45 -0
  47. app/migrations/versions/0023_idempotency_keys.py +38 -0
  48. app/migrations/versions/0024_approvals_view_keyset_index.py +26 -0
  49. app/netguard.py +73 -0
  50. app/obslog.py +105 -0
  51. app/policy.py +390 -0
  52. app/ratelimit.py +78 -0
  53. app/routes/__init__.py +0 -0
  54. app/routes/admin.py +312 -0
  55. app/routes/agents.py +111 -0
  56. app/routes/approvals.py +576 -0
  57. app/routes/audit.py +213 -0
  58. app/routes/costgov.py +150 -0
  59. app/routes/health.py +53 -0
  60. app/routes/policy.py +72 -0
  61. app/routes/runs.py +115 -0
  62. app/routes/slack.py +40 -0
  63. app/routes/teams.py +29 -0
  64. app/routes/traces.py +55 -0
  65. app/schemas.py +248 -0
  66. app/security.py +203 -0
  67. app/siem.py +248 -0
  68. app/signing.py +342 -0
  69. app/sla.py +234 -0
  70. app/slack.py +282 -0
  71. app/teams.py +276 -0
  72. app/tokens.py +117 -0
  73. app/traces.py +309 -0
  74. app/web-dist/assets/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
  75. app/web-dist/assets/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
  76. app/web-dist/assets/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
  77. app/web-dist/assets/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
  78. app/web-dist/assets/geist-mono-cyrillic-ext-wght-normal-I4S5GZfc.woff2 +0 -0
  79. app/web-dist/assets/geist-mono-cyrillic-wght-normal-BmXc_FBt.woff2 +0 -0
  80. app/web-dist/assets/geist-mono-latin-ext-wght-normal-DrnZ1wKl.woff2 +0 -0
  81. app/web-dist/assets/geist-mono-latin-wght-normal-B_7UjwxQ.woff2 +0 -0
  82. app/web-dist/assets/geist-mono-symbols2-wght-normal-GZpp1pK2.woff2 +0 -0
  83. app/web-dist/assets/geist-mono-vietnamese-wght-normal-D8KDMBhC.woff2 +0 -0
  84. app/web-dist/assets/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
  85. app/web-dist/assets/index-B_JDe_G2.js +10 -0
  86. app/web-dist/assets/index-J7J-YtwM.css +1 -0
  87. app/web-dist/index.html +24 -0
  88. app/webhooks.py +405 -0
  89. primarypath_server-1.0.0.dist-info/METADATA +35 -0
  90. primarypath_server-1.0.0.dist-info/RECORD +94 -0
  91. primarypath_server-1.0.0.dist-info/WHEEL +5 -0
  92. primarypath_server-1.0.0.dist-info/entry_points.txt +3 -0
  93. primarypath_server-1.0.0.dist-info/licenses/LICENSE +202 -0
  94. 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})