primarypath-server 1.0.0rc1__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 +712 -0
  6. app/chat.py +115 -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 +1028 -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 +197 -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-9cv9NbqV.css +1 -0
  86. app/web-dist/assets/index-hfcHnKqH.js +10 -0
  87. app/web-dist/index.html +24 -0
  88. app/webhooks.py +405 -0
  89. primarypath_server-1.0.0rc1.dist-info/METADATA +35 -0
  90. primarypath_server-1.0.0rc1.dist-info/RECORD +94 -0
  91. primarypath_server-1.0.0rc1.dist-info/WHEEL +5 -0
  92. primarypath_server-1.0.0rc1.dist-info/entry_points.txt +3 -0
  93. primarypath_server-1.0.0rc1.dist-info/licenses/LICENSE +202 -0
  94. primarypath_server-1.0.0rc1.dist-info/top_level.txt +1 -0
app/__init__.py ADDED
@@ -0,0 +1,351 @@
1
+ """Primary Path approval control plane: app factory."""
2
+ from __future__ import annotations
3
+
4
+ from contextlib import asynccontextmanager
5
+ from pathlib import Path
6
+
7
+ from fastapi import FastAPI, Request
8
+ from fastapi.exceptions import RequestValidationError
9
+ from fastapi.responses import JSONResponse
10
+
11
+ from ._version import VERSION
12
+ from .config import Settings, get_settings
13
+ from .db import make_pool, run_migrations
14
+
15
+ # the release version as VERSION spells it (scripts/version.py stamps
16
+ # _version.py with every other copy; package metadata would say 1.0.0rc1
17
+ # for 1.0.0-rc.1); reported by /healthz, /livez and /api/admin/config so
18
+ # an operator can read what runs
19
+ __version__ = VERSION
20
+
21
+ BUNDLED_WEB_DIST = Path(__file__).resolve().parent / "web-dist"
22
+
23
+
24
+ def resolve_web_dist(settings) -> str | None:
25
+ """Where the console lives: PRIMARYPATH_WEB_DIST when set, else the
26
+ console bundled into the installed wheel (scripts/build-wheels.sh
27
+ copies web/dist to app/web-dist), else nothing (API only)."""
28
+ if settings.web_dist:
29
+ return settings.web_dist
30
+ if (BUNDLED_WEB_DIST / "index.html").is_file():
31
+ return str(BUNDLED_WEB_DIST)
32
+ return None
33
+
34
+
35
+ def create_app(settings: Settings | None = None) -> FastAPI:
36
+ settings = settings or get_settings()
37
+
38
+ from .obslog import setup_logging
39
+
40
+ setup_logging(settings)
41
+
42
+ @asynccontextmanager
43
+ async def lifespan(app: FastAPI):
44
+ from .db import head_revision
45
+
46
+ if settings.migrate_on_start:
47
+ await run_migrations(settings)
48
+ app.state.migration_head = head_revision()
49
+ pool = make_pool(settings)
50
+ await pool.open(wait=True)
51
+ app.state.pool = pool
52
+ app.state.settings = settings
53
+
54
+ from .ledger import Ledger
55
+ from .metrics import Metrics
56
+ from .webhooks import WebhookOutbox
57
+
58
+ app.state.metrics = Metrics()
59
+ app.state.ledger = Ledger(pool)
60
+ app.state.ledger.on_append_seconds = \
61
+ app.state.metrics.append_seconds.observe
62
+ app.state.webhooks = WebhookOutbox(
63
+ pool,
64
+ attempts=settings.webhook_attempts,
65
+ backoff_base=settings.webhook_backoff_base,
66
+ backoff_max=settings.webhook_backoff_max,
67
+ poll_interval_s=settings.webhook_poll_interval_s,
68
+ lease_s=settings.webhook_lease_s,
69
+ secret=settings.webhook_secret,
70
+ metrics=app.state.metrics)
71
+ # every decision surface funnels through Ledger.decide: the callback
72
+ # row commits in the decision's own transaction
73
+ app.state.ledger.decision_hooks.append(app.state.webhooks.on_decision)
74
+
75
+ from .signing import SignerRegistry
76
+
77
+ # every configured backend, selectable per agent evidence profile
78
+ # (F24); .default is what make_signer returned pre-F24
79
+ app.state.signers = SignerRegistry(settings)
80
+ app.state.signer = app.state.signers.default
81
+ app.state.webhooks.signer = app.state.signer # Ed25519 delivery sigs
82
+ # execution grants are minted with the audit key, inside the
83
+ # granting transaction (no signer = no grants, fail closed at the
84
+ # executor)
85
+ if app.state.signer is not None:
86
+ from .grants import GrantMinter
87
+ app.state.ledger.grant_minter = GrantMinter(
88
+ app.state.signer, settings.grant_ttl_s).mint
89
+
90
+ app.state.oidc = None
91
+ if settings.oidc_issuer:
92
+ if not settings.session_secret:
93
+ raise RuntimeError("PRIMARYPATH_SESSION_SECRET is required with OIDC")
94
+ from .auth import OIDC
95
+
96
+ assert settings.oidc_client_id, "PRIMARYPATH_OIDC_CLIENT_ID is required with OIDC"
97
+ app.state.oidc = OIDC(settings.oidc_issuer, settings.oidc_client_id,
98
+ settings.oidc_client_secret or "",
99
+ settings.oidc_redirect_url
100
+ or f"{settings.public_url}/auth/callback",
101
+ settings.oidc_scopes,
102
+ algorithms=[a.strip() for a in
103
+ settings.oidc_algorithms.split(",")
104
+ if a.strip()])
105
+
106
+ app.state.slack = None
107
+ if (settings.slack_bot_token and settings.slack_signing_secret
108
+ and settings.slack_channel):
109
+ from .slack import SlackAPI, SlackRouter
110
+
111
+ app.state.slack = SlackRouter(
112
+ SlackAPI(settings.slack_bot_token), pool, app.state.ledger,
113
+ settings.slack_channel, settings.public_url,
114
+ approver_ids=settings.slack_approver_ids)
115
+
116
+ app.state.teams = None
117
+ app.state.teams_verifier = None
118
+ if (settings.teams_app_id and settings.teams_app_password
119
+ and settings.teams_service_url
120
+ and settings.teams_conversation_id):
121
+ from .teams import TeamsAPI, TeamsRequestVerifier, TeamsRouter
122
+
123
+ app.state.teams = TeamsRouter(
124
+ TeamsAPI(settings.teams_app_id, settings.teams_app_password,
125
+ settings.teams_service_url),
126
+ pool, app.state.ledger, settings.teams_conversation_id,
127
+ settings.public_url,
128
+ approver_ids=settings.teams_approver_ids)
129
+ app.state.teams_verifier = TeamsRequestVerifier(
130
+ settings.teams_app_id,
131
+ openid_config_url=settings.teams_openid_config_url,
132
+ issuer=settings.teams_token_issuer)
133
+
134
+ # every configured chat surface gets notified and reflected
135
+ app.state.chat_routers = [r for r in (app.state.slack, app.state.teams)
136
+ if r is not None]
137
+
138
+ from .agents import AgentRegistry
139
+ from .costgov import CostStore
140
+ from .policy import PolicyStore
141
+ from .tokens import TokenStore
142
+ from .traces import TraceStore
143
+
144
+ app.state.policy = PolicyStore(pool, app.state.ledger, settings)
145
+ app.state.traces = TraceStore(pool, app.state.ledger, settings)
146
+ app.state.tokens = TokenStore(pool)
147
+ app.state.cost = CostStore(pool, app.state.ledger, settings)
148
+ app.state.agents = AgentRegistry(pool, app.state.ledger)
149
+
150
+ # chat surfaces fail closed on approver_groups: give them the policy
151
+ # store so decide_from_chat can refuse actions restricted to IdP groups
152
+ # a chat identity can't prove membership of. (The callback needs no
153
+ # wiring here: a chat decision goes through Ledger.decide like any
154
+ # other, and the outbox hook rides that transaction.)
155
+ for _chat in app.state.chat_routers:
156
+ _chat.policy = app.state.policy
157
+
158
+ from .sla import SLAMonitor
159
+
160
+ app.state.sla = SLAMonitor(app.state.ledger, pool, settings,
161
+ slack=app.state.slack,
162
+ webhooks=app.state.webhooks,
163
+ chat_routers=app.state.chat_routers)
164
+ app.state.sla.start()
165
+
166
+ from .maintenance import Maintenance
167
+
168
+ # housekeeping on its own task: it must not depend on the SLA
169
+ # loop (or any other feature) being switched on
170
+ app.state.maintenance = Maintenance(app.state.ledger, settings)
171
+ app.state.maintenance.start()
172
+
173
+ from .siem import SiemForwarder, make_sink
174
+
175
+ app.state.siem = SiemForwarder(pool, make_sink(settings),
176
+ batch=settings.siem_batch,
177
+ interval_s=settings.siem_interval_s)
178
+ app.state.siem.start()
179
+ app.state.webhooks.start()
180
+ yield
181
+ # rows outlive the process: stop the worker, never "drain" — an
182
+ # undelivered callback waits in the table for the next start
183
+ await app.state.webhooks.stop()
184
+ await app.state.siem.stop()
185
+ await app.state.sla.stop()
186
+ await app.state.maintenance.stop()
187
+ for chat in app.state.chat_routers:
188
+ await chat.drain()
189
+ await pool.close()
190
+
191
+ from .security import (
192
+ BodyLimitMiddleware,
193
+ SecurityHeadersMiddleware,
194
+ build_csp,
195
+ check_session_secret,
196
+ check_transport_posture,
197
+ inline_script_hashes,
198
+ )
199
+
200
+ # fail at construction, not at the first login
201
+ check_session_secret(settings)
202
+ check_transport_posture(settings)
203
+
204
+ app = FastAPI(title="primarypath", lifespan=lifespan)
205
+
206
+ # the console's own inline bootstrap (theme stamp) is hashed into the
207
+ # CSP; anything else inline is refused by the browser
208
+ web_dist = resolve_web_dist(settings)
209
+ _hashes: list[str] = []
210
+ if web_dist:
211
+ from pathlib import Path as _Path
212
+ _index = _Path(web_dist) / "index.html"
213
+ if _index.is_file():
214
+ _hashes = inline_script_hashes(_index.read_text("utf-8"))
215
+ app.state.csp = build_csp(settings, _hashes)
216
+ # outermost: refuse oversized bodies before anything else runs, and
217
+ # stamp the security headers on every response
218
+ app.add_middleware(SecurityHeadersMiddleware, settings=settings,
219
+ csp=app.state.csp)
220
+ app.add_middleware(BodyLimitMiddleware, max_bytes=settings.max_body_bytes)
221
+
222
+ if settings.cors_origins:
223
+ from fastapi.middleware.cors import CORSMiddleware
224
+
225
+ app.add_middleware(
226
+ CORSMiddleware, allow_origins=settings.cors_origins,
227
+ allow_credentials=True, allow_methods=["*"], allow_headers=["*"])
228
+
229
+ import time as _time
230
+
231
+ from fastapi.responses import JSONResponse as _JSON
232
+
233
+ from .obslog import new_request_id, request_id_var
234
+ from .ratelimit import RateLimiter, agent_key, is_agent_write, is_guarded
235
+
236
+ _limiter = RateLimiter(settings.rate_limit_per_minute,
237
+ agent_per_minute=settings.agent_write_rate_per_minute)
238
+
239
+ @app.middleware("http")
240
+ async def rate_limit(request: Request, call_next):
241
+ ip = (request.client.host if request.client else "unknown")
242
+ path = request.url.path
243
+ if is_guarded(request.method, path):
244
+ if not _limiter.allow(ip, path, _time.time()):
245
+ return _JSON({"error": "rate limit exceeded, slow down"},
246
+ status_code=429,
247
+ headers={"Retry-After": "60"})
248
+ elif is_agent_write(request.method, path):
249
+ subject = agent_key(request.headers.get("authorization"), ip)
250
+ if not _limiter.allow_agent_write(subject, _time.time()):
251
+ return _JSON({"error": "agent write quota exceeded for this "
252
+ "credential; retry after the window"},
253
+ status_code=429,
254
+ headers={"Retry-After": "60"})
255
+ return await call_next(request)
256
+
257
+ @app.middleware("http")
258
+ async def observe_requests(request: Request, call_next):
259
+ request_id_var.set(request.headers.get("X-Request-Id")
260
+ or new_request_id())
261
+ start = _time.perf_counter()
262
+ response = await call_next(request)
263
+ elapsed = _time.perf_counter() - start
264
+ metrics = getattr(request.app.state, "metrics", None)
265
+ if metrics is not None:
266
+ route = request.scope.get("route")
267
+ path = getattr(route, "path", request.url.path)
268
+ metrics.http_requests.labels(request.method, path,
269
+ str(response.status_code)).inc()
270
+ metrics.http_seconds.labels(request.method, path).observe(elapsed)
271
+ response.headers["X-Request-Id"] = request_id_var.get() or ""
272
+ return response
273
+
274
+ @app.exception_handler(RequestValidationError)
275
+ async def validation_400(request: Request, exc: RequestValidationError):
276
+ # The contract promises 400 (not FastAPI's default 422) on bad input.
277
+ # loc/msg/type only: a value_error's ctx carries the raw exception
278
+ # object, which is neither serializable nor the client's business.
279
+ detail = [{"loc": e.get("loc"), "msg": e.get("msg"),
280
+ "type": e.get("type")} for e in exc.errors()]
281
+ return JSONResponse({"error": "invalid request", "detail": detail},
282
+ status_code=400)
283
+
284
+ from .ledger import UnsupportedPayload
285
+
286
+ @app.exception_handler(UnsupportedPayload)
287
+ async def unsupported_payload_400(request: Request, exc: UnsupportedPayload):
288
+ return JSONResponse({"error": str(exc)}, status_code=400)
289
+
290
+ from . import auth as auth_module
291
+ from .routes import (
292
+ admin,
293
+ agents,
294
+ approvals,
295
+ audit,
296
+ costgov,
297
+ health,
298
+ policy,
299
+ runs,
300
+ slack,
301
+ teams,
302
+ traces,
303
+ )
304
+
305
+ app.include_router(health.router)
306
+ app.include_router(agents.router)
307
+ app.include_router(approvals.router)
308
+ app.include_router(runs.router)
309
+ app.include_router(audit.router)
310
+ app.include_router(slack.router)
311
+ app.include_router(teams.router)
312
+ app.include_router(auth_module.router)
313
+ app.include_router(admin.router)
314
+ app.include_router(policy.router)
315
+ app.include_router(traces.router)
316
+ app.include_router(costgov.router)
317
+
318
+ if settings.oidc_issuer:
319
+ from fastapi.responses import RedirectResponse
320
+
321
+ from .auth import get_identity
322
+
323
+ @app.middleware("http")
324
+ async def inbox_gate(request: Request, call_next):
325
+ path = request.url.path
326
+ # /teams has its own Bot Framework JWT verification; /metrics,
327
+ # /healthz and /readyz are the operational plane (a 307 to the
328
+ # IdP would break Prometheus and count as probe success for
329
+ # kubelet, defeating the waiting-for-migrations 503 gate).
330
+ open_prefixes = ("/api", "/auth", "/slack", "/teams",
331
+ "/livez", "/healthz", "/readyz", "/metrics")
332
+ if path.startswith(open_prefixes):
333
+ return await call_next(request)
334
+ identity = await get_identity(request)
335
+ if identity is None:
336
+ return RedirectResponse("/auth/login")
337
+ if not identity.get("roles"):
338
+ return JSONResponse(
339
+ {"error": "no role assigned; ask an admin to map your "
340
+ "group or email"}, status_code=403)
341
+ return await call_next(request)
342
+
343
+ if web_dist:
344
+ from pathlib import Path
345
+
346
+ from fastapi.staticfiles import StaticFiles
347
+
348
+ dist = Path(web_dist)
349
+ if dist.is_dir():
350
+ app.mount("/", StaticFiles(directory=dist, html=True), name="inbox")
351
+ return app
app/_version.py ADDED
@@ -0,0 +1,8 @@
1
+ """The release version, spelled exactly as VERSION spells it.
2
+
3
+ Stamped by scripts/version.py with every other copy. It exists because
4
+ package metadata normalizes a prerelease (1.0.0-rc.1 becomes 1.0.0rc1),
5
+ and what runs must name the release the operator deployed, as the image
6
+ tag, the chart and the release page spell it.
7
+ """
8
+ VERSION = "1.0.0-rc.1"
app/agents.py ADDED
@@ -0,0 +1,150 @@
1
+ """The agent registry + per-agent evidence profiles (F24), event-sourced.
2
+
3
+ An agent is the unit of evidence: its own hash chain, its own signing
4
+ backend and key, its own capture depth and retention intent. The registry
5
+ (agent -> unit) and every profile change are `agent_registered` /
6
+ `evidence_set` events on the per-tenant GOVERNANCE chain, folded into
7
+ `agents_view` / `evidence_view` — the log is truth, the tables are
8
+ rebuildable caches (the approvals_view discipline).
9
+
10
+ Retention is recorded intent for export/ops tooling, never enforced by
11
+ deletion: the chain is append-only, and silently dropping records would be
12
+ indistinguishable from tampering.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ from .ledger.store import DEFAULT_TENANT, Ledger
17
+
18
+ # "default" is first-class: sign with whatever the deployment's default
19
+ # signer is (it is also the sentinel an unprofiled agent reports, so a
20
+ # partial profile update can round-trip it)
21
+ SIGNING_BACKENDS = ("default", "none", "file", "kms")
22
+ CAPTURES = ("none", "decisions", "full")
23
+
24
+ DEFAULT_PROFILE = {
25
+ "signing_backend": "default", "key_id": None, "capture": "full",
26
+ "retention_days": None, "source": "default",
27
+ }
28
+
29
+
30
+ class AgentRegistry:
31
+ def __init__(self, pool, ledger: Ledger):
32
+ self.pool = pool
33
+ self.ledger = ledger
34
+
35
+ # -- reads ----------------------------------------------------------------
36
+
37
+ async def agents(self, tenant: str = DEFAULT_TENANT) -> list[dict]:
38
+ """Every agent the tenant knows: the registered map plus any agent
39
+ that has activity but was never mapped (a dev agent riding the
40
+ default chain still shows up, honestly unassigned)."""
41
+ async with self.pool.connection() as conn:
42
+ cur = await conn.execute(
43
+ """
44
+ SELECT a.agent, a.unit, a.updated_by, a.updated_ts_ms,
45
+ e.signing_backend, e.key_id, e.capture,
46
+ e.retention_days, s.events, s.last_ts_ms
47
+ FROM (
48
+ SELECT agent, unit, updated_by, updated_ts_ms
49
+ FROM agents_view WHERE tenant_id = %(t)s
50
+ UNION
51
+ SELECT DISTINCT agent_id, NULL::text, NULL::text,
52
+ NULL::bigint
53
+ FROM events
54
+ WHERE tenant_id = %(t)s AND agent_id IS NOT NULL
55
+ AND agent_id NOT IN
56
+ (SELECT agent FROM agents_view WHERE tenant_id = %(t)s)
57
+ ) a
58
+ LEFT JOIN evidence_view e
59
+ ON e.tenant_id = %(t)s AND e.agent = a.agent
60
+ LEFT JOIN (
61
+ SELECT agent_id, count(*) AS events,
62
+ max(ts_ms) AS last_ts_ms
63
+ FROM events WHERE tenant_id = %(t)s
64
+ AND agent_id IS NOT NULL GROUP BY agent_id
65
+ ) s ON s.agent_id = a.agent
66
+ ORDER BY a.agent
67
+ """, {"t": tenant})
68
+ rows = await cur.fetchall()
69
+ out = []
70
+ for r in rows:
71
+ profile = {
72
+ "signing_backend": r[4] or DEFAULT_PROFILE["signing_backend"],
73
+ "key_id": r[5],
74
+ "capture": r[6] or DEFAULT_PROFILE["capture"],
75
+ "retention_days": r[7],
76
+ }
77
+ out.append({"agent": r[0], "unit": r[1], "updated_by": r[2],
78
+ "updated_ts_ms": r[3], "evidence": profile,
79
+ "events": r[8] or 0, "last_ts_ms": r[9]})
80
+ return out
81
+
82
+ async def known_units(self, tenant: str = DEFAULT_TENANT) -> set[str]:
83
+ """Every unit any agent is (or was) mapped to — what "need-to-know
84
+ is in force" means for this tenant."""
85
+ async with self.pool.connection() as conn:
86
+ cur = await conn.execute(
87
+ """
88
+ SELECT unit FROM agents_view
89
+ WHERE tenant_id = %(t)s AND unit IS NOT NULL
90
+ UNION
91
+ SELECT DISTINCT unit FROM approvals_view
92
+ WHERE tenant_id = %(t)s AND unit IS NOT NULL
93
+ """, {"t": tenant})
94
+ rows = await cur.fetchall()
95
+ return {r[0] for r in rows}
96
+
97
+ async def unit_of(self, agent: str,
98
+ tenant: str = DEFAULT_TENANT) -> str | None:
99
+ async with self.pool.connection() as conn:
100
+ cur = await conn.execute(
101
+ "SELECT unit FROM agents_view WHERE tenant_id = %s "
102
+ "AND agent = %s", (tenant, agent))
103
+ row = await cur.fetchone()
104
+ return row[0] if row else None
105
+
106
+ async def evidence_for(self, agent: str,
107
+ tenant: str = DEFAULT_TENANT) -> dict:
108
+ """The agent's evidence profile; when none is set, the fail-safe
109
+ default: sign with whatever the deployment's default signer is,
110
+ capture everything (`source` says which case this is)."""
111
+ async with self.pool.connection() as conn:
112
+ cur = await conn.execute(
113
+ "SELECT signing_backend, key_id, capture, retention_days "
114
+ "FROM evidence_view WHERE tenant_id = %s AND agent = %s",
115
+ (tenant, agent))
116
+ row = await cur.fetchone()
117
+ if row is None:
118
+ return dict(DEFAULT_PROFILE)
119
+ return {"signing_backend": row[0], "key_id": row[1],
120
+ "capture": row[2], "retention_days": row[3], "source": "set"}
121
+
122
+ # -- writes (governance events) --------------------------------------------
123
+
124
+ async def register(self, agent: str, unit: str | None, by: str,
125
+ reason: str = "",
126
+ tenant: str = DEFAULT_TENANT) -> None:
127
+ """Map (or re-map) an agent to a unit — a chained governance event."""
128
+ await self.ledger.append(
129
+ "agent_registered",
130
+ {"agent": agent, "unit": unit, "by": by, "reason": reason},
131
+ tenant=tenant)
132
+
133
+ async def set_evidence(self, agent: str, *, signing_backend: str,
134
+ key_id: str | None = None, capture: str = "full",
135
+ retention_days: int | None = None, by: str,
136
+ reason: str = "",
137
+ tenant: str = DEFAULT_TENANT) -> dict:
138
+ """Change an agent's evidence demands — itself an audited governance
139
+ event."""
140
+ if signing_backend not in SIGNING_BACKENDS:
141
+ raise ValueError(f"signing_backend must be one of {SIGNING_BACKENDS}")
142
+ if capture not in CAPTURES:
143
+ raise ValueError(f"capture must be one of {CAPTURES}")
144
+ await self.ledger.append(
145
+ "evidence_set",
146
+ {"agent": agent, "signing_backend": signing_backend,
147
+ "key_id": key_id, "capture": capture,
148
+ "retention_days": retention_days, "by": by, "reason": reason},
149
+ tenant=tenant)
150
+ return await self.evidence_for(agent, tenant)
app/alembic.ini ADDED
@@ -0,0 +1,37 @@
1
+ [alembic]
2
+ script_location = migrations
3
+ sqlalchemy.url = postgresql+psycopg://primarypath:primarypath@localhost:5432/primarypath
4
+
5
+ [loggers]
6
+ keys = root,sqlalchemy,alembic
7
+
8
+ [handlers]
9
+ keys = console
10
+
11
+ [formatters]
12
+ keys = generic
13
+
14
+ [logger_root]
15
+ level = WARN
16
+ handlers = console
17
+ qualname =
18
+
19
+ [logger_sqlalchemy]
20
+ level = WARN
21
+ handlers =
22
+ qualname = sqlalchemy.engine
23
+
24
+ [logger_alembic]
25
+ level = INFO
26
+ handlers =
27
+ qualname = alembic
28
+
29
+ [handler_console]
30
+ class = StreamHandler
31
+ args = (sys.stderr,)
32
+ level = NOTSET
33
+ formatter = generic
34
+
35
+ [formatter_generic]
36
+ format = %(levelname)-5.5s [%(name)s] %(message)s
37
+ datefmt = %H:%M:%S