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.
- app/__init__.py +351 -0
- app/_version.py +8 -0
- app/agents.py +150 -0
- app/alembic.ini +37 -0
- app/auth.py +712 -0
- app/chat.py +115 -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 +1028 -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 +197 -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-9cv9NbqV.css +1 -0
- app/web-dist/assets/index-hfcHnKqH.js +10 -0
- app/web-dist/index.html +24 -0
- app/webhooks.py +405 -0
- primarypath_server-1.0.0rc1.dist-info/METADATA +35 -0
- primarypath_server-1.0.0rc1.dist-info/RECORD +94 -0
- primarypath_server-1.0.0rc1.dist-info/WHEEL +5 -0
- primarypath_server-1.0.0rc1.dist-info/entry_points.txt +3 -0
- primarypath_server-1.0.0rc1.dist-info/licenses/LICENSE +202 -0
- 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
|