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/routes/admin.py
ADDED
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from fastapi import APIRouter, Depends, HTTPException, Request
|
|
4
|
+
from pydantic import BaseModel, Field
|
|
5
|
+
|
|
6
|
+
from .. import __version__
|
|
7
|
+
from ..auth import (
|
|
8
|
+
admin_read_activity_enabled,
|
|
9
|
+
get_identity,
|
|
10
|
+
require_admin,
|
|
11
|
+
resolve_tenant,
|
|
12
|
+
)
|
|
13
|
+
from ..ledger.projection import rebuild_projection
|
|
14
|
+
from ..ledger.store import DEFAULT_AGENT
|
|
15
|
+
from ..security import is_secure
|
|
16
|
+
|
|
17
|
+
router = APIRouter(prefix="/api/admin", dependencies=[Depends(require_admin)])
|
|
18
|
+
|
|
19
|
+
# the advertised integrations and the SDK ranges each shim is contract-
|
|
20
|
+
# tested on (pins live in the shim pyprojects; this is what the console
|
|
21
|
+
# and the readiness checklist state to the operator)
|
|
22
|
+
SUPPORTED_SDKS = {
|
|
23
|
+
"openai-agents": ">=0.22,<1",
|
|
24
|
+
"langgraph": ">=1,<2",
|
|
25
|
+
"claude-agent-sdk": ">=0.2,<1",
|
|
26
|
+
"mcp": ">=1.23,<2",
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class BackupVerified(BaseModel):
|
|
31
|
+
note: str = Field(default="", max_length=1024)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class IssueToken(BaseModel):
|
|
35
|
+
kind: str # service | admin
|
|
36
|
+
label: str = Field(default="", max_length=200)
|
|
37
|
+
# F24, service tokens only: the agent this token speaks for and the
|
|
38
|
+
# unit it stamps — the admin maps an agent to a unit right here
|
|
39
|
+
agent: str | None = Field(default=None, max_length=200)
|
|
40
|
+
unit: str | None = Field(default=None, max_length=200)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class ReadActivity(BaseModel):
|
|
44
|
+
"""PUT /api/admin/read-activity — the separation-of-duties toggle."""
|
|
45
|
+
enabled: bool
|
|
46
|
+
reason: str = ""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
async def _admin_actor(request: Request) -> str:
|
|
50
|
+
identity = await get_identity(request)
|
|
51
|
+
if identity:
|
|
52
|
+
return identity.get("email") or identity["subject"]
|
|
53
|
+
return "admin-token"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
async def _record_admin_action(request: Request, action: str,
|
|
57
|
+
detail: dict) -> None:
|
|
58
|
+
"""Administering the governance system is itself a chained, provable
|
|
59
|
+
event: the control plane audits its own operators. Lands in the
|
|
60
|
+
operator's tenant chain (resolved from the auth context, F20)."""
|
|
61
|
+
await request.app.state.ledger.append(
|
|
62
|
+
"admin_action",
|
|
63
|
+
{"action": action, "by": await _admin_actor(request), **detail},
|
|
64
|
+
tenant=await resolve_tenant(request))
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@router.post("/rebuild-projection")
|
|
68
|
+
async def rebuild(request: Request):
|
|
69
|
+
"""Re-fold the caller's tenant's projections from the log, proving they
|
|
70
|
+
are pure caches. Tenant-scoped: one tenant's admin never rebuilds
|
|
71
|
+
another tenant's cache (the F20/FOLLOWUPS seam, closed)."""
|
|
72
|
+
tenant = await resolve_tenant(request)
|
|
73
|
+
async with request.app.state.pool.connection() as conn:
|
|
74
|
+
async with conn.transaction():
|
|
75
|
+
await rebuild_projection(conn, tenant=tenant)
|
|
76
|
+
await _record_admin_action(request, "rebuild_projection", {})
|
|
77
|
+
return {"ok": True}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@router.post("/rotate-key")
|
|
81
|
+
async def rotate_key(request: Request):
|
|
82
|
+
"""Rotate the Ed25519 signing key; old bundles keep verifying via the ring."""
|
|
83
|
+
signer = request.app.state.signer
|
|
84
|
+
if signer is None:
|
|
85
|
+
raise HTTPException(400, "signing is not configured")
|
|
86
|
+
old = signer.key_id
|
|
87
|
+
new = signer.rotate()
|
|
88
|
+
await _record_admin_action(request, "rotate_key",
|
|
89
|
+
{"old_key_id": old, "new_key_id": new})
|
|
90
|
+
return {"key_id": new}
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@router.get("/settings")
|
|
94
|
+
async def settings_summary(request: Request):
|
|
95
|
+
"""What is configured (never the secrets themselves)."""
|
|
96
|
+
s = request.app.state.settings
|
|
97
|
+
return {
|
|
98
|
+
"oidc": bool(s.oidc_issuer),
|
|
99
|
+
"rbac": s.rbac_enabled,
|
|
100
|
+
"slack": bool(s.slack_bot_token and s.slack_channel),
|
|
101
|
+
"service_token": bool(s.service_token),
|
|
102
|
+
"signing_key": bool(s.signing_key_path),
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
# -- F22: the config console (admin-only, read-mostly, mutations audited) -----
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@router.get("/config")
|
|
110
|
+
async def config(request: Request):
|
|
111
|
+
"""How the system is wired: integrations, chat surfaces, SIEM, signing
|
|
112
|
+
key ring — never a secret value. The registry lists the
|
|
113
|
+
frameworks that have checked in via ingested run events per tenant."""
|
|
114
|
+
s = request.app.state.settings
|
|
115
|
+
tenant = await resolve_tenant(request)
|
|
116
|
+
signer = request.app.state.signer
|
|
117
|
+
|
|
118
|
+
# frameworks that have actually governed a run in this tenant
|
|
119
|
+
async with request.app.state.pool.connection() as conn:
|
|
120
|
+
cur = await conn.execute(
|
|
121
|
+
"SELECT DISTINCT payload_json->>'framework' FROM events "
|
|
122
|
+
"WHERE tenant_id = %s AND type = 'approval_requested' "
|
|
123
|
+
"AND payload_json->>'framework' IS NOT NULL", (tenant,))
|
|
124
|
+
frameworks = sorted(r[0] for r in await cur.fetchall())
|
|
125
|
+
|
|
126
|
+
async with request.app.state.pool.connection() as conn:
|
|
127
|
+
cur = await conn.execute(
|
|
128
|
+
"SELECT count(*) FILTER (WHERE status = 'pending'), "
|
|
129
|
+
"count(*) FILTER (WHERE status = 'dead') "
|
|
130
|
+
"FROM webhook_outbox WHERE tenant_id = %s", (tenant,))
|
|
131
|
+
wh_pending, wh_dead = await cur.fetchone()
|
|
132
|
+
cur = await conn.execute(
|
|
133
|
+
"SELECT value, updated_ts_ms FROM config_view "
|
|
134
|
+
"WHERE tenant_id = %s AND key = 'backup_verified'", (tenant,))
|
|
135
|
+
backup = await cur.fetchone()
|
|
136
|
+
|
|
137
|
+
return {
|
|
138
|
+
# the eval-to-production seam, visible: the console's readiness
|
|
139
|
+
# checklist is driven from here, never from guesses
|
|
140
|
+
"insecure": bool(s.insecure),
|
|
141
|
+
# webhook durability: the outbox (pending = in flight or retrying,
|
|
142
|
+
# dead = exhausted retries, replayable)
|
|
143
|
+
"webhooks": {"pending": wh_pending, "dead": wh_dead,
|
|
144
|
+
"secret": bool(s.webhook_secret),
|
|
145
|
+
"attempts": s.webhook_attempts},
|
|
146
|
+
# the operator's attestation that a restore was exercised: the
|
|
147
|
+
# service cannot know, so the checklist asks and records the answer
|
|
148
|
+
"backups": {"verified_ts_ms": backup[1] if backup else None,
|
|
149
|
+
"note": (backup[0] or {}).get("note") if backup else None},
|
|
150
|
+
# transport posture: Secure/__Host- cookies and HSTS follow an https
|
|
151
|
+
# public URL (or PRIMARYPATH_SECURE_COOKIES); visible, never silent
|
|
152
|
+
"transport": {"secure_cookies": is_secure(s), "hsts": is_secure(s),
|
|
153
|
+
"public_url": s.public_url,
|
|
154
|
+
"max_body_bytes": s.max_body_bytes,
|
|
155
|
+
"agent_write_rate_per_minute":
|
|
156
|
+
s.agent_write_rate_per_minute},
|
|
157
|
+
"integrations": {
|
|
158
|
+
"frameworks_seen": frameworks, # drangue, mcp, langgraph, ...
|
|
159
|
+
"slack": bool(s.slack_bot_token and s.slack_channel),
|
|
160
|
+
"teams": bool(s.teams_app_id and s.teams_conversation_id),
|
|
161
|
+
# the framework SDK versions the shims are contract-tested
|
|
162
|
+
# against (server/tests/test_contract_sdks.py, run in CI)
|
|
163
|
+
"pinned": SUPPORTED_SDKS,
|
|
164
|
+
},
|
|
165
|
+
"auth": {
|
|
166
|
+
"oidc": bool(s.oidc_issuer), "rbac": s.rbac_enabled,
|
|
167
|
+
"service_token_env": bool(s.service_token),
|
|
168
|
+
"admin_token_env": bool(s.admin_token),
|
|
169
|
+
},
|
|
170
|
+
"siem": {"sink": s.siem,
|
|
171
|
+
"configured": s.siem != "none"},
|
|
172
|
+
# F23: the no-budget cost posture is a client risk choice — surfaced
|
|
173
|
+
# here so it is visible, never silent.
|
|
174
|
+
"cost": {"default_posture": s.cost_default_posture,
|
|
175
|
+
"default_on_exceed": s.cost_default_on_exceed,
|
|
176
|
+
"prices_configured": bool(s.model_prices)},
|
|
177
|
+
"signing": {"backend": s.signing_backend,
|
|
178
|
+
"key_id": signer.key_id if signer else None,
|
|
179
|
+
"ring": signer.pubring() if signer else {},
|
|
180
|
+
"backends": request.app.state.signers.backends()},
|
|
181
|
+
# execution grants ride the signing key: minted only when it exists
|
|
182
|
+
"grants": {"enabled": signer is not None, "ttl_s": s.grant_ttl_s},
|
|
183
|
+
# F24: the separation-of-duties posture, visible, never silent
|
|
184
|
+
"admin_can_read_activity": await admin_read_activity_enabled(
|
|
185
|
+
request, tenant),
|
|
186
|
+
"version": __version__,
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
@router.get("/tokens")
|
|
191
|
+
async def list_tokens(request: Request):
|
|
192
|
+
"""Issued tokens: metadata only, never the token or its hash."""
|
|
193
|
+
return await request.app.state.tokens.list(
|
|
194
|
+
tenant=await resolve_tenant(request))
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
@router.post("/tokens")
|
|
198
|
+
async def issue_token(request: Request, body: IssueToken):
|
|
199
|
+
"""Issue a service (agent plane) or admin (operator plane) token. The
|
|
200
|
+
plaintext is returned ONCE and never recoverable. Kinds stay distinct —
|
|
201
|
+
the token-split invariant. Audited.
|
|
202
|
+
|
|
203
|
+
A service token may carry {agent, unit}: the agent-to-unit map, stamped
|
|
204
|
+
server-side onto everything the agent writes (F24). Mapping an agent is
|
|
205
|
+
itself a governance event (agent_registered)."""
|
|
206
|
+
if body.kind not in ("service", "admin"):
|
|
207
|
+
raise HTTPException(400, "kind must be 'service' or 'admin'")
|
|
208
|
+
if body.kind != "service" and (body.agent or body.unit):
|
|
209
|
+
raise HTTPException(400, "agent/unit apply to service tokens only")
|
|
210
|
+
if body.unit and not body.agent:
|
|
211
|
+
raise HTTPException(400, "unit requires an agent")
|
|
212
|
+
if body.agent == DEFAULT_AGENT:
|
|
213
|
+
# "default" is the implicit agent of UNPINNED credentials (the env
|
|
214
|
+
# token, open dev mode): a token pinned to that name would share
|
|
215
|
+
# its chain with them and be indistinguishable in the audit. The
|
|
216
|
+
# ledger treats such a token as pinned regardless (pin-ness is
|
|
217
|
+
# credential metadata), but the name is refused up front.
|
|
218
|
+
raise HTTPException(
|
|
219
|
+
400, f"'{DEFAULT_AGENT}' is the implicit agent of unpinned "
|
|
220
|
+
f"credentials; pin the token to a real agent name")
|
|
221
|
+
tenant = await resolve_tenant(request)
|
|
222
|
+
by = await _admin_actor(request)
|
|
223
|
+
token_id, plaintext = await request.app.state.tokens.issue(
|
|
224
|
+
body.kind, body.label, by=by, tenant=tenant,
|
|
225
|
+
agent_id=body.agent, unit=body.unit)
|
|
226
|
+
detail = {"token_id": token_id, "kind": body.kind, "label": body.label}
|
|
227
|
+
if body.agent:
|
|
228
|
+
detail["agent"] = body.agent
|
|
229
|
+
detail["unit"] = body.unit
|
|
230
|
+
await _record_admin_action(request, "issue_token", detail)
|
|
231
|
+
if body.agent and body.unit:
|
|
232
|
+
await request.app.state.agents.register(
|
|
233
|
+
body.agent, body.unit, by=by,
|
|
234
|
+
reason=f"service token {token_id} issued", tenant=tenant)
|
|
235
|
+
return {"id": token_id, "kind": body.kind, "token": plaintext,
|
|
236
|
+
"agent": body.agent, "unit": body.unit,
|
|
237
|
+
"note": "store this now; it is not recoverable"}
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
@router.get("/read-activity")
|
|
241
|
+
async def get_read_activity(request: Request):
|
|
242
|
+
"""The separation-of-duties posture: may the admin role read agent
|
|
243
|
+
activity?"""
|
|
244
|
+
tenant = await resolve_tenant(request)
|
|
245
|
+
return {"enabled": await admin_read_activity_enabled(request, tenant)}
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
@router.put("/read-activity")
|
|
249
|
+
async def set_read_activity(request: Request, body: ReadActivity):
|
|
250
|
+
"""Flip admin_can_read_activity — an event-sourced governance event, so
|
|
251
|
+
enabling break-glass is itself provable (and survives a projection
|
|
252
|
+
rebuild)."""
|
|
253
|
+
tenant = await resolve_tenant(request)
|
|
254
|
+
await request.app.state.ledger.append(
|
|
255
|
+
"admin_action",
|
|
256
|
+
{"action": "set_config", "key": "admin_can_read_activity",
|
|
257
|
+
"value": body.enabled, "by": await _admin_actor(request),
|
|
258
|
+
"reason": body.reason},
|
|
259
|
+
tenant=tenant)
|
|
260
|
+
return {"enabled": body.enabled}
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
@router.delete("/tokens/{token_id}")
|
|
264
|
+
async def revoke_token(request: Request, token_id: str):
|
|
265
|
+
"""Revoke an issued token (tenant-scoped). Audited."""
|
|
266
|
+
tenant = await resolve_tenant(request)
|
|
267
|
+
ok = await request.app.state.tokens.revoke(token_id, tenant)
|
|
268
|
+
if not ok:
|
|
269
|
+
raise HTTPException(404, "unknown or already-revoked token")
|
|
270
|
+
await _record_admin_action(request, "revoke_token", {"token_id": token_id})
|
|
271
|
+
return {"ok": True}
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
# -- webhook outbox (F02): what was delivered, what is waiting, what died ----
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
@router.get("/webhooks")
|
|
278
|
+
async def list_webhooks(request: Request, status: str | None = None,
|
|
279
|
+
limit: int = 100):
|
|
280
|
+
"""Delivery rows, newest first: attempts, next retry, last error.
|
|
281
|
+
`status` ∈ pending/delivered/dead."""
|
|
282
|
+
if status is not None and status not in ("pending", "delivered", "dead"):
|
|
283
|
+
raise HTTPException(400, "status must be pending, delivered or dead")
|
|
284
|
+
return await request.app.state.webhooks.list(
|
|
285
|
+
tenant=await resolve_tenant(request), status=status,
|
|
286
|
+
limit=max(1, min(limit, 500)))
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
@router.post("/webhooks/{delivery_id}/replay")
|
|
290
|
+
async def replay_webhook(request: Request, delivery_id: int):
|
|
291
|
+
"""Re-queue a dead (or delivered) row. Audited."""
|
|
292
|
+
tenant = await resolve_tenant(request)
|
|
293
|
+
ok = await request.app.state.webhooks.replay(delivery_id, tenant)
|
|
294
|
+
if not ok:
|
|
295
|
+
raise HTTPException(404, "unknown delivery, or still pending")
|
|
296
|
+
await _record_admin_action(request, "replay_webhook",
|
|
297
|
+
{"delivery_id": delivery_id})
|
|
298
|
+
return {"ok": True}
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
@router.put("/backup-verified")
|
|
302
|
+
async def backup_verified(request: Request, body: BackupVerified):
|
|
303
|
+
"""The operator attests a restore was exercised (date + note). An
|
|
304
|
+
audited governance event; the readiness checklist reads it back."""
|
|
305
|
+
tenant = await resolve_tenant(request)
|
|
306
|
+
await request.app.state.ledger.append(
|
|
307
|
+
"admin_action",
|
|
308
|
+
{"action": "set_config", "key": "backup_verified",
|
|
309
|
+
"value": {"note": body.note}, "by": await _admin_actor(request),
|
|
310
|
+
"reason": "backup restore verified"},
|
|
311
|
+
tenant=tenant)
|
|
312
|
+
return {"ok": True}
|
app/routes/agents.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from fastapi import APIRouter, Depends, HTTPException, Request
|
|
4
|
+
from pydantic import BaseModel, Field
|
|
5
|
+
|
|
6
|
+
from ..agents import CAPTURES, SIGNING_BACKENDS
|
|
7
|
+
from ..auth import (
|
|
8
|
+
activity_scope,
|
|
9
|
+
admin_read_activity_enabled,
|
|
10
|
+
get_identity,
|
|
11
|
+
require_admin,
|
|
12
|
+
require_reader,
|
|
13
|
+
resolve_tenant,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
router = APIRouter(prefix="/api")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class AgentUpdate(BaseModel):
|
|
20
|
+
"""PUT /api/agents/{agent} — map an agent to a unit and/or set its
|
|
21
|
+
evidence profile. Every change is an audited governance event."""
|
|
22
|
+
unit: str | None = Field(default=None, max_length=200)
|
|
23
|
+
signing_backend: str | None = None # default | none | file | kms
|
|
24
|
+
key_id: str | None = None
|
|
25
|
+
capture: str | None = None # none | decisions | full
|
|
26
|
+
retention_days: int | None = Field(default=None, ge=0)
|
|
27
|
+
reason: str = ""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
async def _actor(request: Request) -> str:
|
|
31
|
+
identity = await get_identity(request)
|
|
32
|
+
if identity:
|
|
33
|
+
return identity.get("email") or identity["subject"]
|
|
34
|
+
return "admin-token"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@router.get("/agents", dependencies=[Depends(require_admin)])
|
|
38
|
+
async def list_agents(request: Request):
|
|
39
|
+
"""Every agent with its unit and evidence profile (admin). The registry
|
|
40
|
+
is metadata about the system, not agent activity — the operator manages
|
|
41
|
+
the map without reading the work."""
|
|
42
|
+
return await request.app.state.agents.agents(
|
|
43
|
+
tenant=await resolve_tenant(request))
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@router.put("/agents/{agent}", dependencies=[Depends(require_admin)])
|
|
47
|
+
async def update_agent(request: Request, agent: str, body: AgentUpdate):
|
|
48
|
+
"""Set an agent's unit and/or evidence profile -> agent_registered /
|
|
49
|
+
evidence_set on the governance chain."""
|
|
50
|
+
if body.signing_backend is not None \
|
|
51
|
+
and body.signing_backend not in SIGNING_BACKENDS:
|
|
52
|
+
raise HTTPException(
|
|
53
|
+
400, f"signing_backend must be one of {SIGNING_BACKENDS}")
|
|
54
|
+
if body.capture is not None and body.capture not in CAPTURES:
|
|
55
|
+
raise HTTPException(400, f"capture must be one of {CAPTURES}")
|
|
56
|
+
|
|
57
|
+
tenant = await resolve_tenant(request)
|
|
58
|
+
registry = request.app.state.agents
|
|
59
|
+
by = await _actor(request)
|
|
60
|
+
sent = body.model_fields_set
|
|
61
|
+
|
|
62
|
+
if "unit" in sent:
|
|
63
|
+
await registry.register(agent, body.unit, by=by, reason=body.reason,
|
|
64
|
+
tenant=tenant)
|
|
65
|
+
if sent & {"signing_backend", "key_id", "capture", "retention_days"}:
|
|
66
|
+
current = await registry.evidence_for(agent, tenant)
|
|
67
|
+
await registry.set_evidence(
|
|
68
|
+
agent,
|
|
69
|
+
signing_backend=body.signing_backend
|
|
70
|
+
or current["signing_backend"],
|
|
71
|
+
key_id=body.key_id if "key_id" in sent else current["key_id"],
|
|
72
|
+
capture=body.capture or current["capture"],
|
|
73
|
+
retention_days=body.retention_days if "retention_days" in sent
|
|
74
|
+
else current["retention_days"],
|
|
75
|
+
by=by, reason=body.reason, tenant=tenant)
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
"agent": agent,
|
|
79
|
+
"unit": await registry.unit_of(agent, tenant),
|
|
80
|
+
"evidence": await registry.evidence_for(agent, tenant),
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@router.get("/viewer", dependencies=[Depends(require_reader)])
|
|
85
|
+
async def viewer(request: Request):
|
|
86
|
+
"""The caller's need-to-know context, for the console: which units it
|
|
87
|
+
can see, whether the admin separation-of-duties lock applies, and
|
|
88
|
+
whether reading as an admin would be recorded (break-glass)."""
|
|
89
|
+
tenant = await resolve_tenant(request)
|
|
90
|
+
# scope only describes what a read WOULD see; it is not itself an
|
|
91
|
+
# activity read, so it never trips the break-glass record
|
|
92
|
+
scope = await activity_scope(request, record=False)
|
|
93
|
+
can_read = await admin_read_activity_enabled(request, tenant)
|
|
94
|
+
if scope is None:
|
|
95
|
+
return {"units": None, "visible_units": None, "units_in_force": False,
|
|
96
|
+
"role": None, "locked": False,
|
|
97
|
+
"admin_can_read_activity": can_read}
|
|
98
|
+
known = await request.app.state.agents.known_units(tenant)
|
|
99
|
+
visible = sorted(scope["units"] & known)
|
|
100
|
+
# locked = separation of duties is actually shutting this admin out:
|
|
101
|
+
# units are in force in this tenant and none of theirs match
|
|
102
|
+
locked = scope["locked"]
|
|
103
|
+
if scope.get("role") == "admin" and not can_read and not locked:
|
|
104
|
+
locked = bool(known) and not visible
|
|
105
|
+
return {"units": sorted(scope["units"]),
|
|
106
|
+
"visible_units": visible,
|
|
107
|
+
"units_in_force": bool(known),
|
|
108
|
+
"agent": scope.get("agent"),
|
|
109
|
+
"role": scope.get("role"),
|
|
110
|
+
"locked": locked,
|
|
111
|
+
"admin_can_read_activity": can_read}
|