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/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]
|