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/sla.py
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
"""SLAs: every pending approval gets a deadline; remind before it,
|
|
2
|
+
escalate at breach. Escalation is notification, not auto-decision — the
|
|
3
|
+
human still owns the call (auto-deny is an explicit opt-in that writes an
|
|
4
|
+
ordinary approval_denied by "system:sla").
|
|
5
|
+
|
|
6
|
+
Notification reach, honestly stated: reminders and escalations post to
|
|
7
|
+
Slack (plus the escalation webhook); an auto-DENY is a decision, so it is
|
|
8
|
+
delivered like one: the agent's callback_url is called and the card is
|
|
9
|
+
reflected on every configured chat surface, Teams included.
|
|
10
|
+
|
|
11
|
+
Reminders/escalations are idempotent on the log itself: the append happens
|
|
12
|
+
in a transaction that holds the append lock and re-checks for an existing
|
|
13
|
+
event, so overlapping scans and restarts can never double-fire. Their
|
|
14
|
+
notifications are outbox rows written in that same transaction (B10): the
|
|
15
|
+
marker and "will be announced" commit together, and a Slack outage delays
|
|
16
|
+
the message instead of losing it. Delivery is at-least-once, like a
|
|
17
|
+
webhook: a post whose success was not recorded is posted again.
|
|
18
|
+
"""
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import asyncio
|
|
22
|
+
import logging
|
|
23
|
+
import time
|
|
24
|
+
|
|
25
|
+
from .ledger import AlreadyDecided, Ledger, UnknownApproval
|
|
26
|
+
from .ledger.store import APPEND_LOCK
|
|
27
|
+
|
|
28
|
+
SLACK_SENDER = "chat:slack"
|
|
29
|
+
|
|
30
|
+
log = logging.getLogger("primarypath.sla")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def resolve_deadline_ms(settings, tool: str, requested_deadline_ms: int | None,
|
|
34
|
+
now_ms: int | None = None) -> int | None:
|
|
35
|
+
if requested_deadline_ms is not None:
|
|
36
|
+
return requested_deadline_ms
|
|
37
|
+
sla_s = settings.sla_for_tool(tool)
|
|
38
|
+
if sla_s is None:
|
|
39
|
+
return None
|
|
40
|
+
return (now_ms if now_ms is not None else int(time.time() * 1000)) + sla_s * 1000
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class SLAMonitor:
|
|
44
|
+
def __init__(self, ledger: Ledger, pool, settings, slack=None,
|
|
45
|
+
webhooks=None, chat_routers=None, now=time.time):
|
|
46
|
+
self.ledger = ledger
|
|
47
|
+
self.pool = pool
|
|
48
|
+
self.settings = settings
|
|
49
|
+
self.slack = slack
|
|
50
|
+
self.webhooks = webhooks
|
|
51
|
+
# every chat surface gets the auto-deny reflected (a Teams card
|
|
52
|
+
# stuck on Approve/Reject after the system already denied would
|
|
53
|
+
# invite decisions on a dead approval)
|
|
54
|
+
self.chat_routers = (chat_routers if chat_routers is not None
|
|
55
|
+
else [r for r in (slack,) if r is not None])
|
|
56
|
+
self.now = now
|
|
57
|
+
self._task: asyncio.Task | None = None
|
|
58
|
+
register = getattr(webhooks, "register_sender", None)
|
|
59
|
+
if slack is not None and register is not None:
|
|
60
|
+
register(SLACK_SENDER, self._deliver_chat)
|
|
61
|
+
|
|
62
|
+
# -- lifecycle ------------------------------------------------------------
|
|
63
|
+
|
|
64
|
+
def start(self) -> None:
|
|
65
|
+
if self.settings.sla_scan_interval_s > 0:
|
|
66
|
+
self._task = asyncio.get_running_loop().create_task(self._loop())
|
|
67
|
+
|
|
68
|
+
async def stop(self) -> None:
|
|
69
|
+
if self._task is not None:
|
|
70
|
+
self._task.cancel()
|
|
71
|
+
try:
|
|
72
|
+
await self._task
|
|
73
|
+
except asyncio.CancelledError:
|
|
74
|
+
pass
|
|
75
|
+
|
|
76
|
+
async def _loop(self) -> None:
|
|
77
|
+
while True:
|
|
78
|
+
try:
|
|
79
|
+
await self.scan()
|
|
80
|
+
except Exception:
|
|
81
|
+
log.exception("sla scan failed; will retry")
|
|
82
|
+
await asyncio.sleep(self.settings.sla_scan_interval_s)
|
|
83
|
+
|
|
84
|
+
# -- one scan pass -----------------------------------------------------------
|
|
85
|
+
|
|
86
|
+
async def scan(self) -> None:
|
|
87
|
+
now_ms = int(self.now() * 1000)
|
|
88
|
+
remind_ms = self.settings.sla_remind_before_seconds * 1000
|
|
89
|
+
async with self.pool.connection() as conn:
|
|
90
|
+
cur = await conn.execute(
|
|
91
|
+
"SELECT tenant_id, approval_id, run_id, tool, deadline_ms, "
|
|
92
|
+
"agent_id, unit FROM approvals_view "
|
|
93
|
+
"WHERE status = 'pending' AND deadline_ms IS NOT NULL "
|
|
94
|
+
"AND deadline_ms <= %s", (now_ms + remind_ms,))
|
|
95
|
+
rows = await cur.fetchall()
|
|
96
|
+
|
|
97
|
+
for tenant, approval_id, run_id, tool, deadline_ms, agent, unit in rows:
|
|
98
|
+
if now_ms >= deadline_ms:
|
|
99
|
+
await self._append_once("approval_escalated", approval_id, {
|
|
100
|
+
"approval_id": approval_id, "run_id": run_id,
|
|
101
|
+
"to": self.settings.sla_escalate_to,
|
|
102
|
+
"reason": "sla_breach"}, tenant, agent, unit,
|
|
103
|
+
notify=self._escalation_rows(
|
|
104
|
+
approval_id, run_id, tool, deadline_ms, tenant))
|
|
105
|
+
# the CONTROL on every scan: expiry must not hang off the
|
|
106
|
+
# one-shot marker or the notification. The row is still
|
|
107
|
+
# pending, so a scan that died after committing the marker
|
|
108
|
+
# is finished by the next one; expire() is idempotent on a
|
|
109
|
+
# decided case.
|
|
110
|
+
if self.settings.sla_breach_auto_deny:
|
|
111
|
+
await self._auto_deny(approval_id, deadline_ms, tenant)
|
|
112
|
+
else:
|
|
113
|
+
await self._append_once("approval_reminded", approval_id, {
|
|
114
|
+
"approval_id": approval_id, "run_id": run_id}, tenant,
|
|
115
|
+
agent, unit,
|
|
116
|
+
notify=self._reminder_rows(approval_id, tool,
|
|
117
|
+
deadline_ms, tenant))
|
|
118
|
+
|
|
119
|
+
async def _append_once(self, type: str, approval_id: str,
|
|
120
|
+
payload: dict, tenant: str, agent: str,
|
|
121
|
+
unit: str | None, notify=None) -> bool:
|
|
122
|
+
"""Append unless an event of this type already exists for the
|
|
123
|
+
approval; check + append share the approval's per-chain append lock
|
|
124
|
+
(F24), so it is race-free. SLA events land on the agent's chain,
|
|
125
|
+
beside the approval they concern. `notify` is a list of outbox rows
|
|
126
|
+
(url, payload, kind) enqueued in the same transaction."""
|
|
127
|
+
async with self.pool.connection() as conn:
|
|
128
|
+
async with conn.transaction():
|
|
129
|
+
await conn.execute(
|
|
130
|
+
"SELECT pg_advisory_xact_lock(%s, hashtext(%s))",
|
|
131
|
+
(APPEND_LOCK, f"{tenant}:{agent}"))
|
|
132
|
+
cur = await conn.execute(
|
|
133
|
+
"SELECT 1 FROM events WHERE tenant_id = %s AND type = %s "
|
|
134
|
+
"AND payload_json->>'approval_id' = %s LIMIT 1",
|
|
135
|
+
(tenant, type, approval_id))
|
|
136
|
+
if await cur.fetchone() is not None:
|
|
137
|
+
return False
|
|
138
|
+
event = await self.ledger._append_in_tx(
|
|
139
|
+
conn, type, payload, tenant, agent_id=agent, unit=unit)
|
|
140
|
+
if self.webhooks is not None:
|
|
141
|
+
for url, body, kind in notify or ():
|
|
142
|
+
await self.webhooks.enqueue_in_tx(
|
|
143
|
+
conn, url, body, tenant=tenant, kind=kind,
|
|
144
|
+
approval_id=approval_id,
|
|
145
|
+
event_hash=event.get("hash"))
|
|
146
|
+
return True
|
|
147
|
+
|
|
148
|
+
# -- notifications ---------------------------------------------------------------
|
|
149
|
+
|
|
150
|
+
def _reminder_rows(self, approval_id: str, tool: str, deadline_ms: int,
|
|
151
|
+
tenant: str) -> list[tuple[str, dict, str]]:
|
|
152
|
+
if self.slack is None:
|
|
153
|
+
return []
|
|
154
|
+
return [(SLACK_SENDER, {
|
|
155
|
+
"message": "sla_reminder", "approval_id": approval_id,
|
|
156
|
+
"tool": tool, "deadline_ms": deadline_ms, "tenant": tenant,
|
|
157
|
+
"channel": self.slack.channel}, "sla_reminder")]
|
|
158
|
+
|
|
159
|
+
def _escalation_rows(self, approval_id: str, run_id: str, tool: str,
|
|
160
|
+
deadline_ms: int,
|
|
161
|
+
tenant: str) -> list[tuple[str, dict, str]]:
|
|
162
|
+
rows = []
|
|
163
|
+
if self.settings.sla_escalation_webhook:
|
|
164
|
+
# the page: signed, retried, dead-lettered, replayable
|
|
165
|
+
rows.append((self.settings.sla_escalation_webhook, {
|
|
166
|
+
"kind": "sla_escalation", "approval_id": approval_id,
|
|
167
|
+
"run_id": run_id, "tool": tool, "deadline_ms": deadline_ms,
|
|
168
|
+
"to": self.settings.sla_escalate_to}, "sla_escalation"))
|
|
169
|
+
if self.slack is not None:
|
|
170
|
+
rows.append((SLACK_SENDER, {
|
|
171
|
+
"message": "sla_escalation", "approval_id": approval_id,
|
|
172
|
+
"run_id": run_id, "tool": tool, "deadline_ms": deadline_ms,
|
|
173
|
+
"tenant": tenant, "to": self.settings.sla_escalate_to,
|
|
174
|
+
"channel": (self.settings.sla_escalation_channel
|
|
175
|
+
or self.slack.channel)}, "sla_escalation_chat"))
|
|
176
|
+
return rows
|
|
177
|
+
|
|
178
|
+
async def _deliver_chat(self, p: dict) -> None:
|
|
179
|
+
"""The outbox's sender for chat:slack rows. Worded at delivery
|
|
180
|
+
time, from the case as it stands then: a message delayed by an
|
|
181
|
+
outage must not invite a decision the case can no longer take.
|
|
182
|
+
Raising leaves the row pending for a retry."""
|
|
183
|
+
a = await self.ledger.approval(p["approval_id"],
|
|
184
|
+
tenant=p.get("tenant", "default"))
|
|
185
|
+
status = a["status"] if a else None
|
|
186
|
+
aid, tool = p["approval_id"], p["tool"]
|
|
187
|
+
case = f"<{self.slack.public_url}/?approval={aid}|"
|
|
188
|
+
if p["message"] == "sla_reminder":
|
|
189
|
+
if status != "pending":
|
|
190
|
+
return # decided while Slack was down: nothing to remind
|
|
191
|
+
left_s = max(0, (p["deadline_ms"] - int(self.now() * 1000))
|
|
192
|
+
// 1000)
|
|
193
|
+
await self.slack.api.post_message(
|
|
194
|
+
p["channel"],
|
|
195
|
+
[{"type": "section", "text": {"type": "mrkdwn", "text":
|
|
196
|
+
f":hourglass_flowing_sand: *Reminder:* `{tool}` approval "
|
|
197
|
+
f"`{aid}` is still pending — {left_s}s to its SLA. "
|
|
198
|
+
f"{case}decide now>"}}],
|
|
199
|
+
text=f"Reminder: {aid} pending")
|
|
200
|
+
return
|
|
201
|
+
# a breach happened and is announced even if the case has since
|
|
202
|
+
# been decided; only the call to action follows the case
|
|
203
|
+
if status == "pending":
|
|
204
|
+
link = f"{case}decide now>"
|
|
205
|
+
elif status == "expired":
|
|
206
|
+
link = f"{case}open the case> (expired; the agent must re-propose)"
|
|
207
|
+
else:
|
|
208
|
+
link = f"{case}open the case> (now {status})"
|
|
209
|
+
await self.slack.api.post_message(
|
|
210
|
+
p["channel"],
|
|
211
|
+
[{"type": "section", "text": {"type": "mrkdwn", "text":
|
|
212
|
+
f":rotating_light: *SLA breached:* `{tool}` approval "
|
|
213
|
+
f"`{aid}` (run `{p['run_id']}`) blew its deadline and "
|
|
214
|
+
f"was escalated to *{p['to']}*. {link}"}}],
|
|
215
|
+
text=f"SLA breached: {aid}")
|
|
216
|
+
|
|
217
|
+
async def _auto_deny(self, approval_id: str, deadline_ms: int,
|
|
218
|
+
tenant: str) -> None:
|
|
219
|
+
"""The deadline passed with no decision: the proposal EXPIRES
|
|
220
|
+
(status `expired`, event approval_expired) — final for the waiting
|
|
221
|
+
agent like a denial, but honest about what happened: nobody said
|
|
222
|
+
no, nobody was there."""
|
|
223
|
+
try:
|
|
224
|
+
a = await self.ledger.expire(
|
|
225
|
+
approval_id,
|
|
226
|
+
reason=f"SLA breached: no decision by deadline {deadline_ms}",
|
|
227
|
+
tenant=tenant)
|
|
228
|
+
except (AlreadyDecided, UnknownApproval):
|
|
229
|
+
return
|
|
230
|
+
# the agent waiting on its callback_url gets called back (outbox
|
|
231
|
+
# row, written by the ledger's hook in the expiry's own
|
|
232
|
+
# transaction), and every chat surface's card flips (not just Slack)
|
|
233
|
+
for chat in self.chat_routers:
|
|
234
|
+
chat.reflect(a)
|
app/slack.py
ADDED
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
"""Slack routing: post approvals to a channel, decide from Slack.
|
|
2
|
+
|
|
3
|
+
A Slack decision is an ordinary approval_granted/approval_denied in the
|
|
4
|
+
same chain as a web decision; the only Slack-specific trace in the audit
|
|
5
|
+
is the reviewer string carrying the Slack identity. Message refs live in
|
|
6
|
+
their own operational table, never in the chain.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import hashlib
|
|
11
|
+
import hmac
|
|
12
|
+
import json
|
|
13
|
+
import logging
|
|
14
|
+
import time
|
|
15
|
+
|
|
16
|
+
import httpx
|
|
17
|
+
|
|
18
|
+
from .chat import ChatRouter
|
|
19
|
+
from .ledger import Ledger
|
|
20
|
+
|
|
21
|
+
log = logging.getLogger("primarypath.slack")
|
|
22
|
+
|
|
23
|
+
SIGNATURE_TOLERANCE_S = 300
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def verify_slack_signature(secret: str, timestamp: str, body: bytes,
|
|
27
|
+
signature: str, now: float | None = None) -> bool:
|
|
28
|
+
"""v0= HMAC over "v0:{timestamp}:{body}"; stale timestamps rejected."""
|
|
29
|
+
try:
|
|
30
|
+
ts = int(timestamp)
|
|
31
|
+
except (TypeError, ValueError):
|
|
32
|
+
return False
|
|
33
|
+
if abs((now if now is not None else time.time()) - ts) > SIGNATURE_TOLERANCE_S:
|
|
34
|
+
return False
|
|
35
|
+
base = b"v0:" + timestamp.encode() + b":" + body
|
|
36
|
+
expected = "v0=" + hmac.new(secret.encode(), base, hashlib.sha256).hexdigest()
|
|
37
|
+
return hmac.compare_digest(expected, signature or "")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _args_summary(arguments: dict, max_items: int = 4) -> str:
|
|
41
|
+
items = list(arguments.items())
|
|
42
|
+
shown = ", ".join(f"{k}={json.dumps(v, ensure_ascii=False)}"
|
|
43
|
+
for k, v in items[:max_items])
|
|
44
|
+
return shown + (", …" if len(items) > max_items else "")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def case_blocks(approval: dict, public_url: str) -> list[dict]:
|
|
48
|
+
reasoning = approval.get("reasoning") or "(no reasoning recorded)"
|
|
49
|
+
if len(reasoning) > 600:
|
|
50
|
+
reasoning = reasoning[:600] + "…"
|
|
51
|
+
return [
|
|
52
|
+
{"type": "section", "text": {"type": "mrkdwn", "text":
|
|
53
|
+
f"*Approval requested:* `{approval['tool']}"
|
|
54
|
+
f"({_args_summary(approval.get('arguments', {}))})`"}},
|
|
55
|
+
{"type": "section", "text": {"type": "mrkdwn", "text": reasoning}},
|
|
56
|
+
{"type": "context", "elements": [{"type": "mrkdwn", "text":
|
|
57
|
+
f"run `{approval['run_id']}` · "
|
|
58
|
+
f"<{public_url}/?approval={approval['approval_id']}|open in inbox>"}]},
|
|
59
|
+
{"type": "actions", "block_id": "primarypath_decision", "elements": [
|
|
60
|
+
{"type": "button", "action_id": "primarypath_approve",
|
|
61
|
+
"text": {"type": "plain_text", "text": "Approve"},
|
|
62
|
+
"style": "primary", "value": approval["approval_id"]},
|
|
63
|
+
{"type": "button", "action_id": "primarypath_reject",
|
|
64
|
+
"text": {"type": "plain_text", "text": "Reject"},
|
|
65
|
+
"style": "danger", "value": approval["approval_id"]},
|
|
66
|
+
]},
|
|
67
|
+
]
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def decided_blocks(approval: dict, public_url: str) -> list[dict]:
|
|
71
|
+
icon = "✅" if approval["status"] == "granted" else "⛔"
|
|
72
|
+
line = (f"{icon} *{approval['status']}* by {approval.get('reviewer')}"
|
|
73
|
+
+ (f": {approval['reason']}" if approval.get("reason") else ""))
|
|
74
|
+
return [
|
|
75
|
+
{"type": "section", "text": {"type": "mrkdwn", "text":
|
|
76
|
+
f"`{approval['tool']}` — {line}"}},
|
|
77
|
+
{"type": "context", "elements": [{"type": "mrkdwn", "text":
|
|
78
|
+
f"run `{approval['run_id']}` · "
|
|
79
|
+
f"<{public_url}/?approval={approval['approval_id']}|audit in inbox>"}]},
|
|
80
|
+
]
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def reject_modal(approval_id: str) -> dict:
|
|
84
|
+
return {
|
|
85
|
+
"type": "modal",
|
|
86
|
+
"callback_id": "primarypath_reject_reason",
|
|
87
|
+
"private_metadata": approval_id,
|
|
88
|
+
"title": {"type": "plain_text", "text": "Reject approval"},
|
|
89
|
+
"submit": {"type": "plain_text", "text": "Reject"},
|
|
90
|
+
"blocks": [
|
|
91
|
+
{"type": "input", "block_id": "reason_block",
|
|
92
|
+
"label": {"type": "plain_text",
|
|
93
|
+
"text": "Reason (recorded in the audit trail)"},
|
|
94
|
+
"element": {"type": "plain_text_input", "action_id": "reason",
|
|
95
|
+
"multiline": True}},
|
|
96
|
+
],
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class SlackAPI:
|
|
101
|
+
"""Thin real client; tests swap in a stub with the same surface."""
|
|
102
|
+
|
|
103
|
+
def __init__(self, token: str):
|
|
104
|
+
self._client = httpx.AsyncClient(
|
|
105
|
+
base_url="https://slack.com/api",
|
|
106
|
+
headers={"Authorization": f"Bearer {token}"}, timeout=10)
|
|
107
|
+
|
|
108
|
+
async def _call(self, method: str, payload: dict) -> dict:
|
|
109
|
+
r = await self._client.post(f"/{method}", json=payload)
|
|
110
|
+
out = r.json()
|
|
111
|
+
if not out.get("ok"):
|
|
112
|
+
log.warning("slack %s failed: %s", method, out.get("error"))
|
|
113
|
+
return out
|
|
114
|
+
|
|
115
|
+
async def post_message(self, channel: str, blocks: list, text: str) -> dict:
|
|
116
|
+
return await self._call("chat.postMessage",
|
|
117
|
+
{"channel": channel, "blocks": blocks, "text": text})
|
|
118
|
+
|
|
119
|
+
async def update_message(self, channel: str, ts: str, blocks: list,
|
|
120
|
+
text: str) -> dict:
|
|
121
|
+
return await self._call("chat.update", {"channel": channel, "ts": ts,
|
|
122
|
+
"blocks": blocks, "text": text})
|
|
123
|
+
|
|
124
|
+
async def open_view(self, trigger_id: str, view: dict) -> dict:
|
|
125
|
+
return await self._call("views.open",
|
|
126
|
+
{"trigger_id": trigger_id, "view": view})
|
|
127
|
+
|
|
128
|
+
async def post_ephemeral(self, channel: str, user: str, text: str) -> dict:
|
|
129
|
+
return await self._call("chat.postEphemeral",
|
|
130
|
+
{"channel": channel, "user": user, "text": text})
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
class SlackRouter(ChatRouter):
|
|
134
|
+
def __init__(self, api, pool, ledger: Ledger, channel: str, public_url: str,
|
|
135
|
+
approver_ids: set[str] | None = None,
|
|
136
|
+
tenant: str = "default"):
|
|
137
|
+
super().__init__(ledger, tenant=tenant)
|
|
138
|
+
self.api = api
|
|
139
|
+
self.pool = pool
|
|
140
|
+
self.channel = channel
|
|
141
|
+
self.public_url = public_url
|
|
142
|
+
# empty set = nobody may decide from Slack (fail closed): the HMAC
|
|
143
|
+
# proves the request came from Slack, not that the user is an approver
|
|
144
|
+
self.approver_ids = approver_ids or set()
|
|
145
|
+
|
|
146
|
+
# -- notify + reflect ------------------------------------------------------
|
|
147
|
+
|
|
148
|
+
def notify(self, approval: dict) -> None:
|
|
149
|
+
self._spawn(self._notify(approval))
|
|
150
|
+
|
|
151
|
+
async def _notify(self, approval: dict) -> None:
|
|
152
|
+
out = await self.api.post_message(
|
|
153
|
+
self.channel, case_blocks(approval, self.public_url),
|
|
154
|
+
text=f"Approval requested: {approval['tool']}")
|
|
155
|
+
if out.get("ok"):
|
|
156
|
+
async with self.pool.connection() as conn:
|
|
157
|
+
await conn.execute(
|
|
158
|
+
"INSERT INTO slack_messages (tenant_id, approval_id, "
|
|
159
|
+
"channel, ts) VALUES (%s, %s, %s, %s) "
|
|
160
|
+
"ON CONFLICT (tenant_id, approval_id) DO NOTHING",
|
|
161
|
+
(approval.get("tenant_id", "default"),
|
|
162
|
+
approval["approval_id"], out["channel"], out["ts"]))
|
|
163
|
+
|
|
164
|
+
def reflect(self, approval: dict) -> None:
|
|
165
|
+
"""Update the Slack message after a decision from any surface."""
|
|
166
|
+
self._spawn(self._reflect(approval))
|
|
167
|
+
|
|
168
|
+
async def _reflect(self, approval: dict) -> None:
|
|
169
|
+
ref = await self._message_ref(approval["approval_id"],
|
|
170
|
+
approval.get("tenant_id", "default"))
|
|
171
|
+
if ref:
|
|
172
|
+
await self.api.update_message(
|
|
173
|
+
ref[0], ref[1], decided_blocks(approval, self.public_url),
|
|
174
|
+
text=f"{approval['status']}: {approval['tool']}")
|
|
175
|
+
|
|
176
|
+
async def _message_ref(self, approval_id: str,
|
|
177
|
+
tenant: str = "default") -> tuple[str, str] | None:
|
|
178
|
+
async with self.pool.connection() as conn:
|
|
179
|
+
cur = await conn.execute(
|
|
180
|
+
"SELECT channel, ts FROM slack_messages "
|
|
181
|
+
"WHERE tenant_id = %s AND approval_id = %s",
|
|
182
|
+
(tenant, approval_id))
|
|
183
|
+
return await cur.fetchone()
|
|
184
|
+
|
|
185
|
+
# -- interactions ----------------------------------------------------------
|
|
186
|
+
|
|
187
|
+
async def handle_interaction(self, payload: dict) -> dict | None:
|
|
188
|
+
kind = payload.get("type")
|
|
189
|
+
if kind == "block_actions":
|
|
190
|
+
return await self._handle_block_action(payload)
|
|
191
|
+
if kind == "view_submission":
|
|
192
|
+
return await self._handle_view_submission(payload)
|
|
193
|
+
return None
|
|
194
|
+
|
|
195
|
+
@staticmethod
|
|
196
|
+
def _reviewer(payload: dict) -> str:
|
|
197
|
+
user = payload.get("user", {})
|
|
198
|
+
display = user.get("name") or user.get("username") or "unknown"
|
|
199
|
+
return f"{display} (slack:{user.get('id', '?')})"
|
|
200
|
+
|
|
201
|
+
def _authorized(self, payload: dict) -> bool:
|
|
202
|
+
return payload.get("user", {}).get("id") in self.approver_ids
|
|
203
|
+
|
|
204
|
+
async def _refuse(self, payload: dict, approval_id: str, verb: str) -> None:
|
|
205
|
+
"""Record the unauthorized attempt in the audit log and tell the
|
|
206
|
+
clicker why nothing happened. Signature-valid != authorized."""
|
|
207
|
+
reviewer = self._reviewer(payload)
|
|
208
|
+
await self.record_refusal(approval_id, reviewer, "Slack", verb)
|
|
209
|
+
channel = (payload.get("channel") or {}).get("id") or self.channel
|
|
210
|
+
user = payload.get("user", {}).get("id", "")
|
|
211
|
+
if user:
|
|
212
|
+
await self.api.post_ephemeral(
|
|
213
|
+
channel, user,
|
|
214
|
+
"You are not an authorized approver for this workspace; "
|
|
215
|
+
"the attempt was recorded in the audit log.")
|
|
216
|
+
|
|
217
|
+
async def _handle_block_action(self, payload: dict) -> dict | None:
|
|
218
|
+
actions = payload.get("actions") or []
|
|
219
|
+
if not actions:
|
|
220
|
+
return None
|
|
221
|
+
action = actions[0]
|
|
222
|
+
approval_id = action.get("value", "")
|
|
223
|
+
verb = {"primarypath_approve": "approve", "primarypath_reject": "reject"}.get(
|
|
224
|
+
action.get("action_id", ""))
|
|
225
|
+
if verb is None:
|
|
226
|
+
return None
|
|
227
|
+
if not self._authorized(payload):
|
|
228
|
+
await self._refuse(payload, approval_id, verb)
|
|
229
|
+
return None
|
|
230
|
+
if verb == "reject":
|
|
231
|
+
await self.api.open_view(payload.get("trigger_id", ""),
|
|
232
|
+
reject_modal(approval_id))
|
|
233
|
+
return None
|
|
234
|
+
await self._decide(approval_id, True, self._reviewer(payload), "",
|
|
235
|
+
channel=(payload.get("channel") or {}).get("id"),
|
|
236
|
+
user=payload.get("user", {}).get("id"))
|
|
237
|
+
return None
|
|
238
|
+
|
|
239
|
+
async def _handle_view_submission(self, payload: dict) -> dict | None:
|
|
240
|
+
view = payload.get("view", {})
|
|
241
|
+
if view.get("callback_id") != "primarypath_reject_reason":
|
|
242
|
+
return None
|
|
243
|
+
approval_id = view.get("private_metadata", "")
|
|
244
|
+
if not self._authorized(payload):
|
|
245
|
+
# the modal click was already gated, but submissions are forgeable
|
|
246
|
+
await self._refuse(payload, approval_id, "reject")
|
|
247
|
+
return {"response_action": "errors",
|
|
248
|
+
"errors": {"reason_block":
|
|
249
|
+
"You are not an authorized approver."}}
|
|
250
|
+
values = view.get("state", {}).get("values", {})
|
|
251
|
+
reason = (values.get("reason_block", {}).get("reason", {})
|
|
252
|
+
.get("value") or "").strip()
|
|
253
|
+
if not reason:
|
|
254
|
+
return {"response_action": "errors",
|
|
255
|
+
"errors": {"reason_block": "A reason is required to reject."}}
|
|
256
|
+
try:
|
|
257
|
+
await self.decide_from_chat(approval_id, False,
|
|
258
|
+
self._reviewer(payload), reason)
|
|
259
|
+
except self.ChatNotPermitted as e:
|
|
260
|
+
await self.record_refusal(approval_id, self._reviewer(payload),
|
|
261
|
+
"Slack", "reject")
|
|
262
|
+
return {"response_action": "errors",
|
|
263
|
+
"errors": {"reason_block": str(e)}}
|
|
264
|
+
approval = await self.ledger.approval(approval_id, tenant=self.tenant)
|
|
265
|
+
if approval is not None:
|
|
266
|
+
await self._reflect(approval)
|
|
267
|
+
return {"response_action": "clear"}
|
|
268
|
+
|
|
269
|
+
async def _decide(self, approval_id: str, approved: bool, reviewer: str,
|
|
270
|
+
reason: str, channel: str | None = None,
|
|
271
|
+
user: str | None = None) -> None:
|
|
272
|
+
try:
|
|
273
|
+
approval = await self.decide_from_chat(approval_id, approved,
|
|
274
|
+
reviewer, reason)
|
|
275
|
+
except self.ChatNotPermitted as e:
|
|
276
|
+
await self.record_refusal(approval_id, reviewer, "Slack",
|
|
277
|
+
"approve" if approved else "reject")
|
|
278
|
+
if channel and user:
|
|
279
|
+
await self.api.post_ephemeral(channel, user, str(e))
|
|
280
|
+
return
|
|
281
|
+
if approval is not None:
|
|
282
|
+
await self._reflect(approval)
|