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/teams.py ADDED
@@ -0,0 +1,276 @@
1
+ """Microsoft Teams routing: post the case as an Adaptive Card, decide from
2
+ Teams, land in the same tamper-evident log as web and Slack decisions.
3
+
4
+ Same discipline as Slack:
5
+ - Incoming activities carry a Bot Framework JWT; it is validated against
6
+ Microsoft's JWKS (iss + aud + exp) before anything else. Unverified =
7
+ approval forgery = 401, nothing written.
8
+ - The clicker must be in the PRIMARYPATH_TEAMS_APPROVERS allowlist of AAD object
9
+ ids; empty means NOBODY (fail closed). Unauthorized attempts are
10
+ refused, answered inline, and recorded as annotations.
11
+ - The decision is an ordinary approval_granted/denied via the shared
12
+ ChatRouter path; only the reviewer string carries the Teams identity.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import logging
18
+ import time
19
+
20
+ import httpx
21
+ import jwt
22
+ from fastapi import HTTPException
23
+
24
+ from .chat import ChatRouter
25
+ from .ledger import Ledger
26
+
27
+ log = logging.getLogger("primarypath.teams")
28
+
29
+ BOTFRAMEWORK_OPENID = "https://login.botframework.com/v1/.well-known/openidconfiguration"
30
+ BOTFRAMEWORK_ISSUER = "https://api.botframework.com"
31
+ LOGIN_URL = ("https://login.microsoftonline.com/botframework.com"
32
+ "/oauth2/v2.0/token")
33
+
34
+
35
+ # -- adaptive cards ---------------------------------------------------------------
36
+
37
+
38
+ def _args_summary(arguments: dict, max_items: int = 6) -> str:
39
+ items = list(arguments.items())
40
+ shown = "\n".join(f"- **{k}**: {json.dumps(v, ensure_ascii=False)}"
41
+ for k, v in items[:max_items])
42
+ return shown + ("\n- …" if len(items) > max_items else "")
43
+
44
+
45
+ def case_card(approval: dict, public_url: str) -> dict:
46
+ reasoning = approval.get("reasoning") or "(no reasoning recorded)"
47
+ if len(reasoning) > 600:
48
+ reasoning = reasoning[:600] + "…"
49
+ aid = approval["approval_id"]
50
+ return {
51
+ "type": "AdaptiveCard", "version": "1.4",
52
+ "$schema": "http://adaptivecards.io/schemas/adaptive-card.json",
53
+ "body": [
54
+ {"type": "TextBlock", "weight": "Bolder",
55
+ "text": f"Approval requested: {approval['tool']}"},
56
+ {"type": "TextBlock", "wrap": True,
57
+ "text": _args_summary(approval.get("arguments", {}))},
58
+ {"type": "TextBlock", "wrap": True, "text": reasoning},
59
+ {"type": "TextBlock", "isSubtle": True, "size": "Small",
60
+ "text": f"run {approval['run_id']} · "
61
+ f"[open in inbox]({public_url}/?approval={aid})"},
62
+ {"type": "Input.Text", "id": "reason", "isMultiline": True,
63
+ "placeholder": "reason (required to reject)"},
64
+ ],
65
+ "actions": [
66
+ {"type": "Action.Submit", "title": "Approve",
67
+ "data": {"verb": "primarypath_approve", "approval_id": aid}},
68
+ {"type": "Action.Submit", "title": "Reject",
69
+ "data": {"verb": "primarypath_reject", "approval_id": aid}},
70
+ ],
71
+ }
72
+
73
+
74
+ def decided_card(approval: dict, public_url: str) -> dict:
75
+ icon = "✅" if approval["status"] == "granted" else "⛔"
76
+ line = (f"{icon} **{approval['status']}** by {approval.get('reviewer')}"
77
+ + (f": {approval['reason']}" if approval.get("reason") else ""))
78
+ aid = approval["approval_id"]
79
+ return {
80
+ "type": "AdaptiveCard", "version": "1.4",
81
+ "$schema": "http://adaptivecards.io/schemas/adaptive-card.json",
82
+ "body": [
83
+ {"type": "TextBlock", "wrap": True,
84
+ "text": f"`{approval['tool']}` — {line}"},
85
+ {"type": "TextBlock", "isSubtle": True, "size": "Small",
86
+ "text": f"run {approval['run_id']} · "
87
+ f"[audit in inbox]({public_url}/?approval={aid})"},
88
+ ],
89
+ "actions": [],
90
+ }
91
+
92
+
93
+ def _card_activity(card: dict) -> dict:
94
+ return {"type": "message", "attachments": [{
95
+ "contentType": "application/vnd.microsoft.card.adaptive",
96
+ "content": card}]}
97
+
98
+
99
+ # -- request verification -------------------------------------------------------------
100
+
101
+
102
+ class TeamsRequestVerifier:
103
+ """Validate the Bot Framework JWT on incoming activities."""
104
+
105
+ def __init__(self, app_id: str, openid_config_url: str = BOTFRAMEWORK_OPENID,
106
+ issuer: str = BOTFRAMEWORK_ISSUER):
107
+ self.app_id = app_id
108
+ self.openid_config_url = openid_config_url
109
+ self.issuer = issuer
110
+ self.http = httpx.AsyncClient(timeout=10)
111
+ self._jwks: dict | None = None
112
+
113
+ async def _jwk_key(self, kid: str | None):
114
+ for attempt in range(2):
115
+ if self._jwks is None or attempt == 1:
116
+ cfg = (await self.http.get(self.openid_config_url)).json()
117
+ self._jwks = (await self.http.get(cfg["jwks_uri"])).json()
118
+ for k in self._jwks.get("keys", []):
119
+ if kid is None or k.get("kid") == kid:
120
+ return jwt.PyJWK(k).key
121
+ raise HTTPException(401, "no matching key for bot token")
122
+
123
+ async def verify(self, authorization: str) -> dict:
124
+ if not authorization.startswith("Bearer "):
125
+ raise HTTPException(401, "missing bot authorization")
126
+ token = authorization.removeprefix("Bearer ").strip()
127
+ try:
128
+ header = jwt.get_unverified_header(token)
129
+ key = await self._jwk_key(header.get("kid"))
130
+ return jwt.decode(token, key=key, algorithms=["RS256"],
131
+ audience=self.app_id, issuer=self.issuer)
132
+ except HTTPException:
133
+ raise
134
+ except jwt.PyJWTError as e:
135
+ raise HTTPException(401, f"invalid bot token: {e}") from None
136
+
137
+
138
+ # -- the API + router ---------------------------------------------------------------
139
+
140
+
141
+ class TeamsAPI:
142
+ """Thin Bot Framework REST client; tests swap in a stub."""
143
+
144
+ def __init__(self, app_id: str, app_password: str, service_url: str):
145
+ self.app_id = app_id
146
+ self.app_password = app_password
147
+ self.service_url = service_url.rstrip("/")
148
+ self.http = httpx.AsyncClient(timeout=10)
149
+ self._token: str | None = None
150
+ self._token_expiry: float = 0.0 # epoch seconds
151
+
152
+ async def _bearer(self) -> str:
153
+ # Bot Framework tokens expire (~1h); refresh a minute early so
154
+ # notifications don't start failing silently after the first hour.
155
+ if self._token is None or time.time() >= self._token_expiry:
156
+ r = await self.http.post(LOGIN_URL, data={
157
+ "grant_type": "client_credentials",
158
+ "client_id": self.app_id, "client_secret": self.app_password,
159
+ "scope": "https://api.botframework.com/.default"})
160
+ r.raise_for_status()
161
+ body = r.json()
162
+ self._token = body["access_token"]
163
+ self._token_expiry = time.time() + max(0, body.get("expires_in", 3600) - 60)
164
+ return self._token
165
+
166
+ async def post_activity(self, conversation_id: str, activity: dict) -> dict:
167
+ r = await self.http.post(
168
+ f"{self.service_url}/v3/conversations/{conversation_id}/activities",
169
+ json=activity,
170
+ headers={"Authorization": f"Bearer {await self._bearer()}"})
171
+ r.raise_for_status()
172
+ return r.json() # {"id": activity_id}
173
+
174
+ async def update_activity(self, conversation_id: str, activity_id: str,
175
+ activity: dict) -> dict:
176
+ r = await self.http.put(
177
+ f"{self.service_url}/v3/conversations/{conversation_id}"
178
+ f"/activities/{activity_id}",
179
+ json=activity,
180
+ headers={"Authorization": f"Bearer {await self._bearer()}"})
181
+ r.raise_for_status()
182
+ return r.json()
183
+
184
+
185
+ class TeamsRouter(ChatRouter):
186
+ def __init__(self, api, pool, ledger: Ledger, conversation_id: str,
187
+ public_url: str, approver_ids: set[str] | None = None,
188
+ tenant: str = "default"):
189
+ super().__init__(ledger, tenant=tenant)
190
+ self.api = api
191
+ self.pool = pool
192
+ self.conversation_id = conversation_id
193
+ self.public_url = public_url
194
+ # AAD object ids; empty = nobody can decide from Teams (fail closed)
195
+ self.approver_ids = approver_ids or set()
196
+
197
+ # -- notify + reflect --------------------------------------------------------
198
+
199
+ def notify(self, approval: dict) -> None:
200
+ self._spawn(self._notify(approval))
201
+
202
+ async def _notify(self, approval: dict) -> None:
203
+ out = await self.api.post_activity(
204
+ self.conversation_id,
205
+ _card_activity(case_card(approval, self.public_url)))
206
+ if out.get("id"):
207
+ async with self.pool.connection() as conn:
208
+ await conn.execute(
209
+ "INSERT INTO teams_messages (tenant_id, approval_id, "
210
+ "conversation_id, activity_id) VALUES (%s, %s, %s, %s) "
211
+ "ON CONFLICT (tenant_id, approval_id) DO NOTHING",
212
+ (approval.get("tenant_id", "default"),
213
+ approval["approval_id"], self.conversation_id, out["id"]))
214
+
215
+ def reflect(self, approval: dict) -> None:
216
+ self._spawn(self._reflect(approval))
217
+
218
+ async def _reflect(self, approval: dict) -> None:
219
+ async with self.pool.connection() as conn:
220
+ cur = await conn.execute(
221
+ "SELECT conversation_id, activity_id FROM teams_messages "
222
+ "WHERE tenant_id = %s AND approval_id = %s",
223
+ (approval.get("tenant_id", "default"),
224
+ approval["approval_id"]))
225
+ ref = await cur.fetchone()
226
+ if ref:
227
+ await self.api.update_activity(
228
+ ref[0], ref[1],
229
+ _card_activity(decided_card(approval, self.public_url)))
230
+
231
+ # -- interactions -----------------------------------------------------------------
232
+
233
+ @staticmethod
234
+ def _reviewer(activity: dict) -> str:
235
+ frm = activity.get("from", {})
236
+ name = frm.get("name") or "unknown"
237
+ aad = frm.get("aadObjectId") or frm.get("id") or "?"
238
+ return f"{name} (aad:{aad})"
239
+
240
+ def _authorized(self, activity: dict) -> bool:
241
+ frm = activity.get("from", {})
242
+ return (frm.get("aadObjectId") or frm.get("id")) in self.approver_ids
243
+
244
+ async def handle_activity(self, activity: dict) -> dict | None:
245
+ """Returns a reply activity dict (or None for silent 200)."""
246
+ value = activity.get("value") or {}
247
+ verb = {"primarypath_approve": "approve", "primarypath_reject": "reject"}.get(
248
+ value.get("verb", ""))
249
+ approval_id = value.get("approval_id", "")
250
+ if verb is None or not approval_id:
251
+ return None
252
+
253
+ reviewer = self._reviewer(activity)
254
+ if not self._authorized(activity):
255
+ await self.record_refusal(approval_id, reviewer, "Teams", verb)
256
+ return {"type": "message",
257
+ "text": "You are not an authorized approver for this "
258
+ "workspace; the attempt was recorded in the "
259
+ "audit log."}
260
+
261
+ reason = (value.get("reason") or "").strip()
262
+ if verb == "reject" and not reason:
263
+ return {"type": "message",
264
+ "text": "A reason is required to reject — add it in the "
265
+ "card's reason box and click Reject again."}
266
+
267
+ try:
268
+ approval = await self.decide_from_chat(
269
+ approval_id, approved=(verb == "approve"),
270
+ reviewer=reviewer, reason=reason)
271
+ except self.ChatNotPermitted as e:
272
+ await self.record_refusal(approval_id, reviewer, "Teams", verb)
273
+ return {"type": "message", "text": str(e)}
274
+ if approval is not None:
275
+ await self._reflect(approval)
276
+ return None
app/tokens.py ADDED
@@ -0,0 +1,117 @@
1
+ """Issued API tokens (F22): the agent plane (service) and operator plane
2
+ (admin) kept distinct, exactly like the static env tokens.
3
+
4
+ Only the sha256 of a token is stored; the plaintext is returned once at
5
+ issue time and never recoverable. A `kind=service` token never satisfies
6
+ admin, and a `kind=admin` token never creates approvals — the token-split
7
+ invariant (F03/F06) holds for issued tokens too. Issuance and revocation
8
+ are recorded as admin_action events in the chain.
9
+
10
+ F24: a service token carries the agent-to-unit map. `agent_id` and `unit`
11
+ are set at issue time by an admin and resolved server-side on every
12
+ request — the agent cannot claim a different agent or unit, the same
13
+ discipline as the tenant seam.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import hashlib
18
+ import secrets
19
+ import time
20
+
21
+ DEFAULT_TENANT = "default"
22
+
23
+
24
+ def _sha256(token: str) -> str:
25
+ return hashlib.sha256(token.encode("utf-8")).hexdigest()
26
+
27
+
28
+ class TokenStore:
29
+ def __init__(self, pool):
30
+ self.pool = pool
31
+
32
+ async def any_active(self, kind: str | None = None) -> bool:
33
+ """Whether any live issued token exists (any tenant). The moment
34
+ one does, the token planes count as CONFIGURED: a deployment that
35
+ issued its tokens from the console and set no env token must not
36
+ keep running with every open-dev fallback wide open.
37
+
38
+ Read from the database on every call, deliberately uncached: this
39
+ is an authentication boundary and the answer is shared state. A
40
+ per-process cache would let one replica issue a token while
41
+ another kept the open-dev fallback until restart (and revoking
42
+ the last token on one replica would leave the others locked).
43
+ The cost is one EXISTS over a partial index, and only on
44
+ deployments with no env credential at all — configured
45
+ deployments short-circuit before reaching here."""
46
+ q = ("SELECT EXISTS (SELECT 1 FROM api_tokens "
47
+ "WHERE revoked_ts_ms IS NULL")
48
+ params: tuple = ()
49
+ if kind is not None:
50
+ q += " AND kind = %s"
51
+ params = (kind,)
52
+ q += ")"
53
+ async with self.pool.connection() as conn:
54
+ cur = await conn.execute(q, params)
55
+ row = await cur.fetchone()
56
+ return bool(row[0])
57
+
58
+ async def issue(self, kind: str, label: str, by: str,
59
+ tenant: str = DEFAULT_TENANT,
60
+ agent_id: str | None = None,
61
+ unit: str | None = None) -> tuple[str, str]:
62
+ """Returns (id, plaintext). The plaintext is shown once."""
63
+ if kind not in ("service", "admin"):
64
+ raise ValueError("kind must be 'service' or 'admin'")
65
+ if kind != "service" and (agent_id or unit):
66
+ raise ValueError("agent/unit apply to service tokens only")
67
+ token_id = "tok_" + secrets.token_hex(6)
68
+ plaintext = f"pp_{kind}_{secrets.token_urlsafe(32)}"
69
+ async with self.pool.connection() as conn:
70
+ await conn.execute(
71
+ "INSERT INTO api_tokens (id, tenant_id, kind, token_sha256, "
72
+ "label, created_ts_ms, created_by, agent_id, unit) "
73
+ "VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)",
74
+ (token_id, tenant, kind, _sha256(plaintext), label,
75
+ int(time.time() * 1000), by, agent_id, unit))
76
+ return token_id, plaintext
77
+
78
+ async def revoke(self, token_id: str,
79
+ tenant: str = DEFAULT_TENANT) -> bool:
80
+ """Tenant-scoped: an admin of one tenant cannot revoke another's
81
+ token by id (the F20 seam; inert while there is one default tenant)."""
82
+ async with self.pool.connection() as conn:
83
+ cur = await conn.execute(
84
+ "UPDATE api_tokens SET revoked_ts_ms = %s "
85
+ "WHERE id = %s AND tenant_id = %s AND revoked_ts_ms IS NULL",
86
+ (int(time.time() * 1000), token_id, tenant))
87
+ return cur.rowcount > 0
88
+
89
+ async def verify(self, presented: str, kind: str) -> dict | None:
90
+ """The live token's record if a token of this kind matches — the
91
+ server-side source of the agent/unit stamp (never the request
92
+ body) and of the credential identity that decisions are
93
+ attributed to (id + label, P3)."""
94
+ async with self.pool.connection() as conn:
95
+ cur = await conn.execute(
96
+ "SELECT tenant_id, agent_id, unit, id, label FROM api_tokens "
97
+ "WHERE token_sha256 = %s AND kind = %s "
98
+ "AND revoked_ts_ms IS NULL",
99
+ (_sha256(presented), kind))
100
+ row = await cur.fetchone()
101
+ if row is None:
102
+ return None
103
+ return {"tenant_id": row[0], "agent_id": row[1], "unit": row[2],
104
+ "id": row[3], "label": row[4]}
105
+
106
+ async def list(self, tenant: str = DEFAULT_TENANT) -> list[dict]:
107
+ """Metadata only — never the token or its hash."""
108
+ async with self.pool.connection() as conn:
109
+ cur = await conn.execute(
110
+ "SELECT id, kind, label, created_ts_ms, created_by, "
111
+ "revoked_ts_ms, agent_id, unit FROM api_tokens "
112
+ "WHERE tenant_id = %s ORDER BY created_ts_ms DESC", (tenant,))
113
+ rows = await cur.fetchall()
114
+ return [{"id": r[0], "kind": r[1], "label": r[2],
115
+ "created_ts_ms": r[3], "created_by": r[4],
116
+ "revoked": r[5] is not None, "agent": r[6], "unit": r[7]}
117
+ for r in rows]