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/chat.py ADDED
@@ -0,0 +1,122 @@
1
+ """The shared chat-routing core: Slack and Teams differ only in transport.
2
+
3
+ Every chat surface does the same things: post the case when an approval is
4
+ requested, write the decision through the SAME ledger path as the web, and
5
+ reflect the outcome back onto the original message. Nothing chat-specific
6
+ ever enters the audit chain beyond the reviewer string.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import logging
12
+
13
+ from .ledger import AlreadyDecided, Ledger, UnknownApproval
14
+ from .ledger.store import DEFAULT_TENANT
15
+
16
+ log = logging.getLogger("primarypath.chat")
17
+
18
+
19
+ class ChatRouter:
20
+ def __init__(self, ledger: Ledger, tenant: str = DEFAULT_TENANT,
21
+ policy=None):
22
+ self.ledger = ledger
23
+ # a chat surface serves one tenant (F20 seam); multi-tenant chat
24
+ # means one router per tenant workspace, not a shared one
25
+ self.tenant = tenant
26
+ # set after construction; used to fail closed on approver_groups
27
+ self.policy = policy
28
+ self._tasks: set[asyncio.Task] = set()
29
+
30
+ class ChatNotPermitted(Exception):
31
+ """The action's approver_groups can't be proven from a chat identity."""
32
+
33
+ # -- background plumbing ---------------------------------------------------
34
+
35
+ def _spawn(self, coro) -> None:
36
+ task = asyncio.get_running_loop().create_task(coro)
37
+ self._tasks.add(task)
38
+ task.add_done_callback(self._tasks.discard)
39
+
40
+ async def drain(self) -> None:
41
+ # drop finished tasks here rather than trust the done-callback to
42
+ # have run: a finished task still in the set made `while self._tasks`
43
+ # spin forever (three chat tests timed out in CI for rc.5), and
44
+ # shutdown drains the same way
45
+ while True:
46
+ self._tasks.difference_update([t for t in self._tasks if t.done()])
47
+ if not self._tasks:
48
+ return
49
+ await asyncio.gather(*list(self._tasks), return_exceptions=True)
50
+
51
+ # -- surface hooks (transport-specific) --------------------------------------
52
+
53
+ def notify(self, approval: dict) -> None:
54
+ raise NotImplementedError
55
+
56
+ def reflect(self, approval: dict) -> None:
57
+ raise NotImplementedError
58
+
59
+ # -- the one shared decision path ---------------------------------------------
60
+
61
+ async def decide_from_chat(self, approval_id: str, approved: bool,
62
+ reviewer: str, reason: str = "",
63
+ tenant: str | None = None) -> dict | None:
64
+ """Write the decision exactly like the web path would, into this
65
+ surface's tenant chain. On a race, return the standing decision so
66
+ the caller can show who won.
67
+
68
+ Fail closed on approver_groups and on dual control: a chat identity
69
+ (Slack/Teams user) cannot prove IdP group membership, and the chat
70
+ card has no endorsed-awaiting-second state, so actions that pin
71
+ approver_groups or demand a second approver are REFUSED here and
72
+ must be decided in the web inbox. Otherwise the chat allowlist
73
+ would silently satisfy a stronger control. Raises ChatNotPermitted."""
74
+ tenant = tenant or self.tenant
75
+ await self._require_chat_decidable(approval_id, tenant)
76
+ try:
77
+ a = await self.ledger.decide(approval_id, approved=approved,
78
+ reviewer=reviewer, reason=reason,
79
+ tenant=tenant)
80
+ except AlreadyDecided:
81
+ # the racing decider already called the agent back
82
+ return await self.ledger.approval(approval_id, tenant=tenant)
83
+ except UnknownApproval:
84
+ log.warning("chat decision for unknown approval %s", approval_id)
85
+ return None
86
+ # the agent's callback_url is served by the ledger's decision hook
87
+ # (outbox row in the decision transaction) — same as a web decision
88
+ return a
89
+
90
+ async def _require_chat_decidable(self, approval_id: str,
91
+ tenant: str) -> None:
92
+ if self.policy is None:
93
+ return
94
+ a = await self.ledger.approval(approval_id, tenant=tenant)
95
+ if a is None:
96
+ return
97
+ # the case's snapshot binds as well as the live rule (B6)
98
+ live = await self.policy.policy_for(a["tool"], tenant)
99
+ stored = a.get("policy") or {}
100
+ if any(r and (r.get("approver_groups") or []) for r in (stored, live)):
101
+ raise self.ChatNotPermitted(
102
+ "this action restricts approvers to specific groups; "
103
+ "decide it in the web inbox with your SSO identity")
104
+ if any(r and r.get("require_second_approval") for r in (stored, live)):
105
+ raise self.ChatNotPermitted(
106
+ "this action requires two approvers (dual control); "
107
+ "decide it in the web inbox")
108
+
109
+ async def record_refusal(self, approval_id: str, reviewer: str,
110
+ surface: str, verb: str,
111
+ tenant: str | None = None) -> None:
112
+ """An unauthorized click is refused AND becomes part of the record."""
113
+ log.warning("unauthorized %s %s attempt on %s by %s",
114
+ surface, verb, approval_id, reviewer)
115
+ try:
116
+ await self.ledger.annotate(
117
+ approval_id, reviewer,
118
+ f"unauthorized {surface} {verb} attempt refused "
119
+ f"(user not in the approver allowlist)",
120
+ tenant=tenant or self.tenant)
121
+ except UnknownApproval:
122
+ pass
app/config.py ADDED
@@ -0,0 +1,258 @@
1
+ """Service settings, env-driven. Only database_url is required to boot."""
2
+ from __future__ import annotations
3
+
4
+ from functools import lru_cache
5
+
6
+ from pydantic import field_validator
7
+ from pydantic_settings import BaseSettings, SettingsConfigDict
8
+
9
+
10
+ class Settings(BaseSettings):
11
+ model_config = SettingsConfigDict(env_file=".env", env_prefix="PRIMARYPATH_", extra="ignore")
12
+
13
+ # compose passes unset optionals as empty strings; treat those as unset
14
+ @field_validator(
15
+ "signing_key_path", "oidc_issuer", "oidc_client_id",
16
+ "oidc_client_secret", "oidc_redirect_url", "session_secret",
17
+ "service_token", "admin_token", "slack_bot_token", "slack_signing_secret",
18
+ "signing_ring_path", "pkcs11_module", "pkcs11_token_label", "pkcs11_pin",
19
+ "siem_endpoint", "siem_token", "teams_app_id", "teams_app_password",
20
+ "teams_service_url", "teams_conversation_id",
21
+ "slack_channel", "web_dist",
22
+ "sla_default_seconds", "sla_escalation_channel",
23
+ "sla_escalation_webhook", "webhook_secret", "csp", "secure_cookies",
24
+ mode="before")
25
+ @classmethod
26
+ def _empty_env_is_none(cls, v):
27
+ return None if v == "" else v
28
+
29
+ database_url: str = "postgresql://primarypath:primarypath@localhost:5432/primarypath"
30
+ host: str = "127.0.0.1" # non-loopback binds require auth or insecure=true
31
+ port: int = 8123
32
+ insecure: bool = False # explicit opt-in to expose an unauthenticated deploy
33
+ # k8s: a pre-install/pre-upgrade Job runs migrations instead, so N
34
+ # replicas don't race Alembic at startup
35
+ migrate_on_start: bool = True
36
+
37
+ # F18 — observability
38
+ log_json: bool = True
39
+ log_level: str = "info"
40
+
41
+ # CORS: empty = no cross-origin (the inbox is same-origin with the API).
42
+ # Set a csv of allowed origins only if a separate front-end needs them.
43
+ cors_allow_origins: str = ""
44
+
45
+ # Rate limiting on abuse-sensitive endpoints (auth, decisions, chat
46
+ # interactions) — per client IP, fixed 1-minute window. 0 disables. Reads
47
+ # and agent polling are never limited. In-memory and per-replica; put a
48
+ # limiter at your ingress too for multi-replica deployments.
49
+ rate_limit_per_minute: int = 60
50
+ # per-credential fixed-window limit on authenticated AGENT writes
51
+ # (create approval, ingest run events, evals, gate). Reads and
52
+ # polling are never limited. In-memory, per replica.
53
+ agent_write_rate_per_minute: int = 600
54
+ # bodies over this are 413 before a handler runs
55
+ max_body_bytes: int = 1_048_576
56
+
57
+ # transport posture (TLS terminates at the ingress). None = derive
58
+ # from public_url (https => secure). Secure => cookies are Secure and
59
+ # __Host-prefixed, and HSTS is sent.
60
+ secure_cookies: bool | None = None
61
+ # override the computed Content-Security-Policy (see app/security.py)
62
+ csp: str | None = None
63
+ session_secret_min_length: int = 32
64
+
65
+ @property
66
+ def cors_origins(self) -> list[str]:
67
+ return [o.strip() for o in self.cors_allow_origins.split(",") if o.strip()]
68
+
69
+ # static inbox build (F04): path to web/dist; served at / when set
70
+ web_dist: str | None = None
71
+
72
+ # base URL used in outbound links (Slack messages, etc.)
73
+ public_url: str = "http://127.0.0.1:8123"
74
+
75
+ # webhook delivery (F02): a transactional outbox with a leased worker.
76
+ # attempts is the dead-letter threshold; backoff is base*2^n capped at
77
+ # max; the worker leases a row for lease_s per attempt (a crashed
78
+ # replica's row is retried by another after that); secret enables the
79
+ # HMAC signature header (the Ed25519 one rides on the audit key).
80
+ webhook_attempts: int = 10
81
+ webhook_backoff_base: float = 0.5
82
+ webhook_backoff_max: float = 300.0
83
+ webhook_poll_interval_s: float = 1.0 # <= 0 disables the worker
84
+ webhook_lease_s: float = 30.0
85
+ webhook_secret: str | None = None
86
+ # SSRF guard: callback_url targeting private/loopback hosts is refused
87
+ # unless this is set (self-hosted internal webhooks). The cloud-metadata
88
+ # link-local range is always refused.
89
+ webhook_allow_private: bool = False
90
+
91
+ # F09/F15 — audit signing. Backend "file" (default) or "pkcs11" (the
92
+ # private key lives in the customer's HSM/KMS and never leaves it).
93
+ signing_backend: str = "file"
94
+ signing_key_path: str | None = None
95
+ signing_ring_path: str | None = None # pkcs11 mode: where the pubring lives
96
+ pkcs11_module: str | None = None
97
+ pkcs11_token_label: str | None = None
98
+ pkcs11_pin: str | None = None
99
+ pkcs11_key_label: str = "primarypath-audit"
100
+ # execution grants: how long an executor has to redeem the grant minted
101
+ # with a decision. Grants need a signer (file or pkcs11); without one,
102
+ # approvals carry no grant and a grant-requiring executor refuses.
103
+ grant_ttl_s: int = 300
104
+ # how long an Idempotency-Key is remembered (a retry later than this
105
+ # is a new request); expired by the maintenance loop below
106
+ idempotency_ttl_s: int = 7 * 24 * 3600
107
+ # housekeeping cadence (idempotency-key expiry), independent of every
108
+ # feature loop; <= 0 disables it, with a startup warning, and nothing
109
+ # then expires the keys
110
+ maintenance_interval_s: float = 60.0
111
+
112
+ # F06 — human plane (OIDC sessions) and agent plane (service token)
113
+ oidc_issuer: str | None = None
114
+ oidc_client_id: str | None = None
115
+ oidc_client_secret: str | None = None
116
+ oidc_redirect_url: str | None = None
117
+ oidc_scopes: str = "openid profile email"
118
+ oidc_algorithms: str = "RS256" # csv; e.g. "RS256,ES256" per your IdP
119
+ session_secret: str | None = None
120
+ session_ttl_s: int = 8 * 3600
121
+ # agent plane: create approvals, ingest run events, poll status. This
122
+ # token must never satisfy admin surfaces — every governed agent holds it.
123
+ service_token: str | None = None
124
+ # operator plane: full audit export, key rotation, admin endpoints.
125
+ admin_token: str | None = None
126
+
127
+ # F08 — SLAs. Deadlines resolve request > per-tool > default; None = no SLA.
128
+ sla_default_seconds: int | None = None
129
+ sla_by_tool: str = "" # "refund:600,rollback_deploy:120"
130
+ sla_remind_before_seconds: int = 300
131
+ sla_escalate_to: str = "ops"
132
+ sla_escalation_channel: str | None = None
133
+ sla_escalation_webhook: str | None = None
134
+ sla_breach_auto_deny: bool = False
135
+ sla_scan_interval_s: float = 5.0 # <= 0 disables the background loop
136
+
137
+ def sla_for_tool(self, tool: str) -> int | None:
138
+ for part in self.sla_by_tool.split(","):
139
+ if ":" in part:
140
+ name, _, secs = part.strip().partition(":")
141
+ if name == tool and secs.strip().isdigit():
142
+ return int(secs)
143
+ return self.sla_default_seconds
144
+
145
+ # F17 — Microsoft Teams routing (mirrors Slack; off when unconfigured)
146
+ teams_app_id: str | None = None
147
+ teams_app_password: str | None = None
148
+ teams_service_url: str | None = None
149
+ teams_conversation_id: str | None = None
150
+ # AAD object ids allowed to decide; empty = NOBODY (fail closed)
151
+ teams_approvers: str = ""
152
+ teams_openid_config_url: str = (
153
+ "https://login.botframework.com/v1/.well-known/openidconfiguration")
154
+ teams_token_issuer: str = "https://api.botframework.com"
155
+
156
+ @property
157
+ def teams_approver_ids(self) -> set[str]:
158
+ return {x.strip() for x in self.teams_approvers.split(",") if x.strip()}
159
+
160
+ # F21 — policy & rollout. Enforcement consults live policy_view; an
161
+ # action with no rule falls back to fail-safe (require approval).
162
+ policy_default_requires_approval: bool = True
163
+ policy_graduation_min_decisions: int = 10
164
+ policy_graduation_approval_rate: float = 0.95
165
+ policy_demotion_rejection_rate: float = 0.2
166
+ # the rollout track record counts approvals and incidents from the
167
+ # last N days (0 = all history); reported on every row as window_days
168
+ policy_rollout_lookback_days: int = 90
169
+
170
+ # F22 — traces & cost. Optional per-model prices, USD per 1M tokens:
171
+ # "claude-opus-4-8:15/75,claude-sonnet-5:3/15". Empty -> tokens only,
172
+ # cost null (honest: tokens are exact, cost is an estimate).
173
+ model_prices: str = ""
174
+
175
+ # F23 — cost governance. The no-budget posture is the client's risk
176
+ # choice, never hard-coded. "open" (default): an action no budget covers
177
+ # has no cost gate. "closed": it falls back to cost_default_on_exceed.
178
+ # Either way the F21 safety gate stays independent and fail-safe.
179
+ cost_default_posture: str = "open" # open | closed
180
+ cost_default_on_exceed: str = "gate" # gate | deny
181
+
182
+ # F24 — admin separation of duties. When false (the shipped default),
183
+ # the admin ROLE grants no read of agent activity: visibility follows
184
+ # unit membership, and an admin in no unit sees policy/budgets/tokens/
185
+ # agents but no inbox, runs, or per-agent audit. When true, an admin
186
+ # reading activity is break-glass: every such read appends a chained
187
+ # admin_action. Deployments without a human plane (no OIDC) are the
188
+ # single-trust dev posture and are not gated. A runtime toggle (an
189
+ # event-sourced set_config admin_action) overrides this default.
190
+ admin_can_read_activity: bool = False
191
+
192
+ # F16 — SIEM forwarding, off by default. Never blocks the append path.
193
+ siem: str = "none" # none | splunk | elastic | syslog
194
+ siem_endpoint: str | None = None
195
+ siem_token: str | None = None
196
+ siem_index: str = "primarypath-audit"
197
+ siem_syslog_host: str = "127.0.0.1"
198
+ siem_syslog_port: int = 6514
199
+ siem_syslog_protocol: str = "tcp" # tcp | udp
200
+ siem_batch: int = 500
201
+ siem_interval_s: float = 2.0 # <= 0 disables the loop
202
+
203
+ # F07 — RBAC. Comma-separated. Default deny, always (auth.resolve_roles):
204
+ # once OIDC is on, a user matching none of these gets NO roles and no
205
+ # access. Set at least rbac_admin_emails before anyone can use the inbox.
206
+ rbac_admin_groups: str = ""
207
+ rbac_approver_groups: str = ""
208
+ rbac_admin_emails: str = ""
209
+
210
+ @staticmethod
211
+ def _csv(v: str) -> set[str]:
212
+ return {x.strip() for x in v.split(",") if x.strip()}
213
+
214
+ @property
215
+ def admin_groups(self) -> set[str]:
216
+ return self._csv(self.rbac_admin_groups)
217
+
218
+ @property
219
+ def approver_groups(self) -> set[str]:
220
+ return self._csv(self.rbac_approver_groups)
221
+
222
+ @property
223
+ def admin_emails(self) -> set[str]:
224
+ return self._csv(self.rbac_admin_emails)
225
+
226
+ @property
227
+ def rbac_enabled(self) -> bool:
228
+ return bool(self.admin_groups or self.approver_groups or self.admin_emails)
229
+
230
+ # F05
231
+ slack_bot_token: str | None = None
232
+ slack_signing_secret: str | None = None
233
+ slack_channel: str | None = None
234
+ # Slack user IDs allowed to decide (comma-separated). Empty = NOBODY can
235
+ # decide from Slack (fail closed): a verified signature proves the
236
+ # request came from Slack, not that the clicker is an approver.
237
+ slack_approvers: str = ""
238
+
239
+ @property
240
+ def slack_approver_ids(self) -> set[str]:
241
+ return {x.strip() for x in self.slack_approvers.split(",") if x.strip()}
242
+
243
+ # F10
244
+
245
+ @property
246
+ def alembic_url(self) -> str:
247
+ """SQLAlchemy URL for migrations (sync psycopg driver)."""
248
+ url = self.database_url
249
+ if url.startswith("postgresql://"):
250
+ return url.replace("postgresql://", "postgresql+psycopg://", 1)
251
+ if url.startswith("postgres://"):
252
+ return url.replace("postgres://", "postgresql+psycopg://", 1)
253
+ return url
254
+
255
+
256
+ @lru_cache
257
+ def get_settings() -> Settings:
258
+ return Settings()