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/auth.py ADDED
@@ -0,0 +1,712 @@
1
+ """SSO (OIDC) for reviewers + service-token auth for agents.
2
+
3
+ Two distinct auth planes, kept deliberately separate:
4
+ - Humans: OIDC Authorization Code flow with PKCE -> server session row ->
5
+ signed httpOnly cookie. Every decision then carries a verified SSO
6
+ subject, not a typed name.
7
+ - Agents: a bearer service token from settings. Agents are not humans; a
8
+ human session never authorizes agent-facing endpoints and vice versa.
9
+
10
+ Both planes are off (open, dev mode) until configured in settings.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import base64
15
+ import hashlib
16
+ import hmac
17
+ import json
18
+ import secrets
19
+ import time
20
+ from urllib.parse import urlencode
21
+
22
+ import httpx
23
+ import jwt
24
+ from fastapi import APIRouter, HTTPException, Request
25
+ from fastapi.responses import JSONResponse, RedirectResponse
26
+
27
+ SESSION_COOKIE = "primarypath_session"
28
+ FLOW_COOKIE = "primarypath_auth_flow"
29
+
30
+
31
+ def session_cookie(settings) -> str:
32
+ """`__Host-primarypath_session` on a secure deployment (app/security)."""
33
+ from .security import cookie_name
34
+ return cookie_name(SESSION_COOKIE, settings)
35
+
36
+
37
+ def flow_cookie(settings) -> str:
38
+ from .security import cookie_name
39
+ return cookie_name(FLOW_COOKIE, settings)
40
+
41
+
42
+ # -- cookie signing (hmac, stdlib) --------------------------------------------
43
+
44
+ def _b64(data: bytes) -> str:
45
+ return base64.urlsafe_b64encode(data).decode().rstrip("=")
46
+
47
+
48
+ def _unb64(s: str) -> bytes:
49
+ return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4))
50
+
51
+
52
+ def sign_value(secret: str, obj: dict) -> str:
53
+ payload = _b64(json.dumps(obj, separators=(",", ":")).encode())
54
+ sig = _b64(hmac.new(secret.encode(), payload.encode(), hashlib.sha256).digest())
55
+ return f"{payload}.{sig}"
56
+
57
+
58
+ def unsign_value(secret: str, value: str) -> dict | None:
59
+ try:
60
+ payload, sig = value.split(".", 1)
61
+ expected = _b64(hmac.new(secret.encode(), payload.encode(),
62
+ hashlib.sha256).digest())
63
+ if not hmac.compare_digest(expected, sig):
64
+ return None
65
+ return json.loads(_unb64(payload))
66
+ except Exception:
67
+ return None
68
+
69
+
70
+ # -- OIDC client ---------------------------------------------------------------
71
+
72
+ class OIDC:
73
+ def __init__(self, issuer: str, client_id: str, client_secret: str,
74
+ redirect_url: str, scopes: str,
75
+ algorithms: list[str] | None = None):
76
+ self.issuer = issuer.rstrip("/")
77
+ self.client_id = client_id
78
+ self.client_secret = client_secret
79
+ self.redirect_url = redirect_url
80
+ self.scopes = scopes
81
+ self.algorithms = algorithms or ["RS256"]
82
+ self.http = httpx.AsyncClient(timeout=10)
83
+ self._config: dict | None = None
84
+ self._jwks: dict | None = None
85
+
86
+ async def config(self) -> dict:
87
+ if self._config is None:
88
+ r = await self.http.get(
89
+ f"{self.issuer}/.well-known/openid-configuration")
90
+ r.raise_for_status()
91
+ self._config = r.json()
92
+ return self._config
93
+
94
+ async def _jwk_key(self, kid: str | None):
95
+ for attempt in range(2):
96
+ if self._jwks is None or attempt == 1:
97
+ cfg = await self.config()
98
+ r = await self.http.get(cfg["jwks_uri"])
99
+ r.raise_for_status()
100
+ self._jwks = r.json()
101
+ for k in self._jwks.get("keys", []):
102
+ if kid is None or k.get("kid") == kid:
103
+ return jwt.PyJWK(k).key
104
+ raise HTTPException(401, "no matching JWKS key for id_token")
105
+
106
+ async def exchange_code(self, code: str, verifier: str) -> dict:
107
+ cfg = await self.config()
108
+ r = await self.http.post(cfg["token_endpoint"], data={
109
+ "grant_type": "authorization_code", "code": code,
110
+ "redirect_uri": self.redirect_url, "client_id": self.client_id,
111
+ "client_secret": self.client_secret, "code_verifier": verifier})
112
+ if r.status_code != 200:
113
+ raise HTTPException(401, "token exchange failed")
114
+ return r.json()
115
+
116
+ async def validate_id_token(self, token: str, nonce: str) -> dict:
117
+ try:
118
+ header = jwt.get_unverified_header(token)
119
+ key = await self._jwk_key(header.get("kid"))
120
+ claims = jwt.decode(token, key=key, algorithms=self.algorithms,
121
+ audience=self.client_id, issuer=self.issuer)
122
+ except HTTPException:
123
+ raise
124
+ except jwt.PyJWTError as e:
125
+ raise HTTPException(401, f"invalid id_token: {e}") from None
126
+ if claims.get("nonce") != nonce:
127
+ raise HTTPException(401, "id_token nonce mismatch")
128
+ return claims
129
+
130
+ def auth_url_params(self, cfg: dict, state: str, nonce: str,
131
+ challenge: str) -> str:
132
+ return cfg["authorization_endpoint"] + "?" + urlencode({
133
+ "response_type": "code", "client_id": self.client_id,
134
+ "redirect_uri": self.redirect_url, "scope": self.scopes,
135
+ "state": state, "nonce": nonce,
136
+ "code_challenge": challenge, "code_challenge_method": "S256"})
137
+
138
+
139
+ # -- sessions -------------------------------------------------------------------
140
+
141
+ async def create_session(pool, secret: str, ttl_s: int, claims: dict,
142
+ roles: list[str]) -> tuple[str, str]:
143
+ sid = secrets.token_urlsafe(24)
144
+ csrf = secrets.token_urlsafe(24)
145
+ groups = list(claims.get("groups") or claims.get("roles") or [])
146
+ async with pool.connection() as conn:
147
+ # opportunistic cleanup: expired rows are dead weight (they never
148
+ # authenticate; get_identity checks expires_at) and logins are the
149
+ # natural, low-frequency moment to sweep them
150
+ await conn.execute("DELETE FROM sessions WHERE expires_at <= %s",
151
+ (int(time.time()),))
152
+ await conn.execute(
153
+ "INSERT INTO sessions (id, subject, email, name, roles, groups, "
154
+ "csrf, expires_at) VALUES (%s, %s, %s, %s, %s::jsonb, %s::jsonb, "
155
+ "%s, %s)",
156
+ (sid, claims["sub"], claims.get("email"),
157
+ claims.get("name") or claims.get("preferred_username"),
158
+ json.dumps(roles), json.dumps(groups), csrf,
159
+ int(time.time()) + ttl_s))
160
+ return sid, sign_value(secret, {"sid": sid})
161
+
162
+
163
+ async def get_identity(request: Request) -> dict | None:
164
+ """The verified session identity, or None."""
165
+ settings = request.app.state.settings
166
+ if not settings.session_secret:
167
+ return None
168
+ raw = request.cookies.get(session_cookie(settings))
169
+ if not raw:
170
+ return None
171
+ data = unsign_value(settings.session_secret, raw)
172
+ if not data or "sid" not in data:
173
+ return None
174
+ async with request.app.state.pool.connection() as conn:
175
+ cur = await conn.execute(
176
+ "SELECT id, subject, email, name, roles, csrf, groups FROM sessions "
177
+ "WHERE id = %s AND expires_at > %s", (data["sid"], int(time.time())))
178
+ row = await cur.fetchone()
179
+ if row is None:
180
+ return None
181
+ return {"sid": row[0], "subject": row[1], "email": row[2], "name": row[3],
182
+ "roles": row[4], "csrf": row[5], "groups": row[6]}
183
+
184
+
185
+ def reviewer_string(identity: dict) -> str:
186
+ return (f"{identity.get('name') or '?'} <{identity.get('email') or '?'}> "
187
+ f"(sub:{identity['subject']})")
188
+
189
+
190
+ def resolve_roles(claims: dict, settings) -> list[str]:
191
+ """Map OIDC claims to roles. Default deny, always: once an IdP is wired,
192
+ an unmapped user gets no roles and no access — map at least one admin
193
+ via rbac_admin_emails before anyone can use the inbox."""
194
+ groups = set(claims.get("groups") or claims.get("roles") or [])
195
+ email = (claims.get("email") or "").lower()
196
+ roles: list[str] = []
197
+ if (groups & settings.admin_groups
198
+ or (email and email in {e.lower() for e in settings.admin_emails})):
199
+ roles.append("admin")
200
+ if roles or groups & settings.approver_groups:
201
+ if "approver" not in roles:
202
+ roles.append("approver")
203
+ return roles
204
+
205
+
206
+ async def resolve_tenant(request: Request) -> str:
207
+ """The tenant comes from the AUTH CONTEXT, never from a request field —
208
+ a client-chosen tenant would be a cross-tenant hole. The API surface is
209
+ single-tenant today: every credential maps to the default tenant. This
210
+ function is the seam where a token->tenant or SSO-claim->tenant map
211
+ plugs in (F20 built the data layer; the product surface is deferred)."""
212
+ from .ledger.store import DEFAULT_TENANT
213
+
214
+ return DEFAULT_TENANT
215
+
216
+
217
+ async def resolve_agent(request: Request) -> tuple[str, str | None]:
218
+ """(agent_id, unit) for an agent-plane request, resolved server-side
219
+ from the presented service token's record — the same discipline as
220
+ resolve_tenant: never from the request body, so an agent cannot claim
221
+ a different agent or unit (F24). The env service token and open dev
222
+ mode ride the default agent."""
223
+ from .ledger.store import DEFAULT_AGENT
224
+
225
+ rec = await _issued_token_record(request, "service")
226
+ if rec is not None and rec.get("agent_id"):
227
+ unit = rec.get("unit")
228
+ if unit is None:
229
+ # a token issued before the agent was mapped still stamps the
230
+ # registry's current unit
231
+ unit = await request.app.state.agents.unit_of(
232
+ rec["agent_id"], await resolve_tenant(request))
233
+ return rec["agent_id"], unit
234
+ return DEFAULT_AGENT, None
235
+
236
+
237
+ async def agent_credential(request: Request) -> str:
238
+ """The audit identity of the agent-plane credential presenting this
239
+ request, derived server-side — never typed by the caller. An issued
240
+ service token names the agent it is pinned to AND the revocable
241
+ credential (`agent:<id> (token:<token id> <label>)`), the same
242
+ binding decisions get under issued admin tokens; the env service
243
+ token is single-trust and says so; open dev mode says so too."""
244
+ from .ledger.store import DEFAULT_AGENT
245
+
246
+ rec = await _issued_token_record(request, "service")
247
+ if rec is not None:
248
+ agent = rec.get("agent_id") or DEFAULT_AGENT
249
+ label = rec.get("label")
250
+ bind = f"token:{rec['id']}" + (f" {label}" if label else "")
251
+ return f"agent:{agent} ({bind})"
252
+ if _service_ok(request):
253
+ return f"agent:{DEFAULT_AGENT} (env service token)"
254
+ return f"agent:{DEFAULT_AGENT} (open, no auth configured)"
255
+
256
+
257
+ async def credential_id(request: Request) -> str:
258
+ """A stable, non-secret name for the presenting credential, for
259
+ idempotency fingerprints: issued tokens by id, env tokens by plane,
260
+ sessions by subject, open mode as such."""
261
+ for kind in ("service", "admin"):
262
+ rec = await _issued_token_record(request, kind)
263
+ if rec is not None:
264
+ return f"token:{rec['id']}"
265
+ if _service_ok(request):
266
+ return "env:service"
267
+ if _admin_token_ok(request):
268
+ return "env:admin"
269
+ identity = await get_identity(request)
270
+ if identity is not None:
271
+ return f"user:{identity.get('subject')}"
272
+ return "open"
273
+
274
+
275
+ async def agent_pin(request: Request) -> str | None:
276
+ """The agent this credential is PINNED to, or None for an unpinned
277
+ credential (the env service token, open dev mode). Read from the
278
+ issued token record — never inferred from the agent's name: a token
279
+ pinned to an agent that happens to be called "default" is pinned.
280
+ Ownership checks (idempotent reuse, cancel, redeem) take this, so
281
+ only an unpinned credential has the single-trust posture."""
282
+ rec = await _issued_token_record(request, "service")
283
+ if rec is not None and rec.get("agent_id"):
284
+ return rec["agent_id"]
285
+ return None
286
+
287
+
288
+ async def require_agent_binding(request: Request, agent: str) -> None:
289
+ """Evidence writes that NAME an agent (evals, deploy gates) must not
290
+ outrun the credential: a service token pinned to agent A could
291
+ otherwise record evals for agent B or squat B's gate — forged
292
+ evidence under a real signature. Unpinned tokens (the env token, open
293
+ dev) are the single-trust posture and may write for any agent."""
294
+ rec = await _issued_token_record(request, "service")
295
+ if rec is not None and rec.get("agent_id") and rec["agent_id"] != agent:
296
+ raise HTTPException(
297
+ 403, f"token is bound to agent '{rec['agent_id']}' and cannot "
298
+ f"write evidence for '{agent}'")
299
+
300
+
301
+ def reviewer_role(identity: dict | None) -> str | None:
302
+ if identity is None:
303
+ return None
304
+ roles = identity.get("roles") or []
305
+ return "admin" if "admin" in roles else ("approver" if "approver" in roles else None)
306
+
307
+
308
+ # -- route dependencies -----------------------------------------------------------
309
+
310
+ def _human_plane_on(request: Request) -> bool:
311
+ return bool(request.app.state.settings.oidc_issuer)
312
+
313
+
314
+ def _service_token(request: Request) -> str | None:
315
+ auth = request.headers.get("Authorization", "")
316
+ return auth.removeprefix("Bearer ").strip() if auth.startswith("Bearer ") else None
317
+
318
+
319
+ def _service_ok(request: Request) -> bool:
320
+ expected = request.app.state.settings.service_token
321
+ got = _service_token(request)
322
+ return bool(expected and got and hmac.compare_digest(expected, got))
323
+
324
+
325
+ def _admin_token_ok(request: Request) -> bool:
326
+ expected = request.app.state.settings.admin_token
327
+ got = _service_token(request)
328
+ return bool(expected and got and hmac.compare_digest(expected, got))
329
+
330
+
331
+ async def _issued_token_record(request: Request, kind: str) -> dict | None:
332
+ """The issued (DB) token record of the given kind (F22/F24), or None.
333
+ Kept distinct from the other plane: a service token never verifies as
334
+ admin and vice versa. The token's tenant must match the request's
335
+ resolved tenant, so a token issued for tenant B never authenticates a
336
+ request resolving to A (the F20 seam; inert while resolve_tenant is the
337
+ default tenant)."""
338
+ store = getattr(request.app.state, "tokens", None)
339
+ tok = _service_token(request)
340
+ if store is None or not tok:
341
+ return None
342
+ rec = await store.verify(tok, kind)
343
+ if rec is None or rec["tenant_id"] != await resolve_tenant(request):
344
+ return None
345
+ return rec
346
+
347
+
348
+ async def _issued_token_ok(request: Request, kind: str) -> bool:
349
+ return await _issued_token_record(request, kind) is not None
350
+
351
+
352
+ async def _service_ok_any(request: Request) -> bool:
353
+ return _service_ok(request) or await _issued_token_ok(request, "service")
354
+
355
+
356
+ async def _admin_ok_any(request: Request) -> bool:
357
+ return _admin_token_ok(request) or await _issued_token_ok(request, "admin")
358
+
359
+
360
+ async def _any_auth_configured(request: Request) -> bool:
361
+ """Auth counts as configured the moment an OPERATOR credential exists —
362
+ any env setting, or a console-issued ADMIN token. Without the issued
363
+ clause, a deployment that issued its tokens from the console and set
364
+ no env token would grade as unconfigured and run every open-dev
365
+ fallback wide open, the issued tokens purely decorative.
366
+
367
+ Deliberately asymmetric per plane: an issued SERVICE token closes only
368
+ the agent plane (require_agent), not the operator surfaces — the
369
+ console has no bearer mode, so issuing an agent token from the open
370
+ evaluation console must not brick the very session that issued it.
371
+ Locking the operator planes is what issuing an operator credential
372
+ means; and the bind guard still refuses a network-reachable bind with
373
+ no env auth, so the open evaluation posture stays loopback-only."""
374
+ s = request.app.state.settings
375
+ if s.oidc_issuer or s.service_token or s.admin_token:
376
+ return True
377
+ store = getattr(request.app.state, "tokens", None)
378
+ return store is not None and await store.any_active("admin")
379
+
380
+
381
+ async def is_agent_plane(request: Request) -> bool:
382
+ """Whether this request is authenticated as an agent (a service token,
383
+ or open dev mode where no plane is configured). The execution grant's
384
+ signature is returned only here: it is a bearer capability for one
385
+ consequential action, meant for the executor, not for the console."""
386
+ if not await _any_auth_configured(request):
387
+ return True
388
+ return await _service_ok_any(request)
389
+
390
+
391
+ async def require_agent(request: Request) -> None:
392
+ """Agent-facing endpoints: service token only (never a human session).
393
+ The plane is configured by the env service token OR any issued
394
+ service token."""
395
+ if request.app.state.settings.service_token is None:
396
+ store = getattr(request.app.state, "tokens", None)
397
+ if store is None or not await store.any_active("service"):
398
+ return # plane not configured: open (dev)
399
+ if not await _service_ok_any(request):
400
+ raise HTTPException(401, "service token required")
401
+
402
+
403
+ def _require_role(identity: dict, role: str) -> None:
404
+ if role not in (identity.get("roles") or []):
405
+ raise HTTPException(403, f"{role} role required")
406
+
407
+
408
+ async def require_reader(request: Request) -> None:
409
+ """Read endpoints: an approver session or a service/admin token, once
410
+ auth is on."""
411
+ if not await _any_auth_configured(request):
412
+ return
413
+ if await _service_ok_any(request) or await _admin_ok_any(request):
414
+ return
415
+ if _human_plane_on(request):
416
+ identity = await get_identity(request)
417
+ if identity is not None:
418
+ _require_role(identity, "approver")
419
+ return
420
+ raise HTTPException(401, "authentication required")
421
+
422
+
423
+ async def require_console_reader(request: Request) -> None:
424
+ """Human-console reads (F22 traces): an approver+ session or an ADMIN
425
+ token — NOT the bare agent service token. Traces surface every run's
426
+ reasoning, tool arguments/results, and tenant-wide cost; the low-
427
+ privilege token every governed agent carries must not exfiltrate them.
428
+ (Approval reads keep the service token because the agent polls its own
429
+ wait_for_decision; a trace console has no such need.)"""
430
+ if not await _any_auth_configured(request):
431
+ return
432
+ if await _admin_ok_any(request):
433
+ return
434
+ if _human_plane_on(request):
435
+ identity = await get_identity(request)
436
+ if identity is not None:
437
+ _require_role(identity, "approver")
438
+ return
439
+ raise HTTPException(401, "approver session or admin token required")
440
+
441
+
442
+ async def require_reviewer(request: Request) -> dict | None:
443
+ """Human mutating endpoints: approver session + CSRF when the human
444
+ plane is on.
445
+
446
+ Returns the identity (None in open/dev mode, where the request body
447
+ must carry a reviewer name instead).
448
+
449
+ Token-only deployments (tokens configured, no OIDC) require the ADMIN
450
+ token: reads are token-gated there, so writes must be too, and the
451
+ agent service token must never decide the approvals it created.
452
+ """
453
+ if not _human_plane_on(request):
454
+ if not await _any_auth_configured(request):
455
+ return None # nothing configured: open dev mode
456
+ rec = await _issued_token_record(request, "admin")
457
+ if rec is not None:
458
+ # an ISSUED (per-person) admin token: bind the recorded
459
+ # reviewer to the credential, so token-mode decisions name a
460
+ # revocable identity, not just a typed string
461
+ return {"token_id": rec["id"], "token_label": rec.get("label"),
462
+ "roles": ["admin"]}
463
+ if _admin_token_ok(request):
464
+ return None # env admin token; body names the reviewer
465
+ raise HTTPException(401, "admin token or sign-in required")
466
+ identity = await get_identity(request)
467
+ if identity is None:
468
+ raise HTTPException(401, "sign in required")
469
+ header = request.headers.get("X-CSRF-Token", "")
470
+ if not hmac.compare_digest(identity["csrf"], header):
471
+ raise HTTPException(403, "missing or bad CSRF token")
472
+ _require_role(identity, "approver")
473
+ return identity
474
+
475
+
476
+ async def require_admin(request: Request) -> dict | None:
477
+ """Admin endpoints: an admin session or the dedicated ADMIN token. The
478
+ agent service token never satisfies this — every governed agent carries
479
+ that token, and it must not be able to rotate keys or export the full
480
+ audit log. Open only when no auth at all is configured (dev)."""
481
+ if not await _any_auth_configured(request):
482
+ return None
483
+ if await _admin_ok_any(request):
484
+ return None
485
+ if _human_plane_on(request):
486
+ identity = await get_identity(request)
487
+ if identity is not None:
488
+ header = request.headers.get("X-CSRF-Token", "")
489
+ if (request.method not in ("GET", "HEAD")
490
+ and not hmac.compare_digest(identity["csrf"], header)):
491
+ raise HTTPException(403, "missing or bad CSRF token")
492
+ _require_role(identity, "admin")
493
+ return identity
494
+ raise HTTPException(401, "authentication required")
495
+
496
+
497
+ # -- the need-to-know read gate (F24) -----------------------------------------------
498
+
499
+ UNRESTRICTED = None # scope value: no unit filtering applies
500
+ _BREAK_GLASS_DEDUP_S = 600
501
+
502
+
503
+ async def admin_read_activity_enabled(request: Request, tenant: str) -> bool:
504
+ """Effective admin_can_read_activity: the runtime (event-sourced) toggle
505
+ when one has been set, else the configured default (off — separation of
506
+ duties ships closed)."""
507
+ async with request.app.state.pool.connection() as conn:
508
+ cur = await conn.execute(
509
+ "SELECT value FROM config_view WHERE tenant_id = %s "
510
+ "AND key = 'admin_can_read_activity'", (tenant,))
511
+ row = await cur.fetchone()
512
+ if row is not None:
513
+ return bool(row[0])
514
+ return request.app.state.settings.admin_can_read_activity
515
+
516
+
517
+ async def record_break_glass(request: Request, tenant: str, actor: str,
518
+ what: str) -> None:
519
+ """An admin reading activity under admin_can_read_activity is itself a
520
+ chained governance event — break-glass is provable. Deduped in memory
521
+ per (tenant, actor) so a polling console appends one record per window,
522
+ not one per request."""
523
+ dedup = getattr(request.app.state, "_break_glass_seen", None)
524
+ if dedup is None:
525
+ dedup = request.app.state._break_glass_seen = {}
526
+ now = time.time()
527
+ last = dedup.get((tenant, actor))
528
+ if last is not None and now - last < _BREAK_GLASS_DEDUP_S:
529
+ return
530
+ dedup[(tenant, actor)] = now
531
+ await request.app.state.ledger.append(
532
+ "admin_action",
533
+ {"action": "read_activity", "by": actor, "detail": what},
534
+ tenant=tenant)
535
+
536
+
537
+ async def activity_scope(request: Request, record: bool = True) -> dict | None:
538
+ """What activity this caller may READ. Visibility follows the unit,
539
+ never the admin role (F24).
540
+
541
+ Returns UNRESTRICTED (None) when no gate applies — open dev mode, the
542
+ single-trust env tokens without a human plane, or an admin under an
543
+ enabled (and audited) admin_can_read_activity. Otherwise:
544
+
545
+ {"units": set[str], # agents in these units are visible
546
+ "agent": str|None, # an issued service token pinned to its agent
547
+ "role": str|None, # 'admin' drives the console's locked state
548
+ "locked": bool} # admin barred by separation of duties
549
+
550
+ Agents with NO unit assigned stay visible to any authorized reader
551
+ (enforced in the queries) — need-to-know starts when units are mapped,
552
+ so pre-F24 installs do not go dark.
553
+
554
+ The caller is assumed already authenticated (require_reader et al.);
555
+ this only narrows what an authenticated reader sees.
556
+ """
557
+ if not await _any_auth_configured(request):
558
+ return UNRESTRICTED
559
+ tenant = await resolve_tenant(request)
560
+
561
+ # agent plane: an issued token pinned to an agent reads only that agent
562
+ # (its own queue and decisions); tokens without a pin are the
563
+ # single-trust dev posture and read unrestricted
564
+ svc = await _issued_token_record(request, "service")
565
+ if svc is not None:
566
+ if svc.get("agent_id"):
567
+ return {"units": set(), "agent": svc["agent_id"], "role": None,
568
+ "locked": False}
569
+ return UNRESTRICTED
570
+ if _service_ok(request):
571
+ return UNRESTRICTED
572
+
573
+ # operator plane without a human plane configured is the same
574
+ # single-trust posture (dev/tests); with OIDC on, the admin token is an
575
+ # operator credential and separation of duties applies to it
576
+ if await _admin_ok_any(request):
577
+ if not _human_plane_on(request):
578
+ return UNRESTRICTED
579
+ if await admin_read_activity_enabled(request, tenant):
580
+ if record:
581
+ await record_break_glass(request, tenant, "admin-token",
582
+ request.url.path)
583
+ return UNRESTRICTED
584
+ return {"units": set(), "agent": None, "role": "admin",
585
+ "locked": True}
586
+
587
+ identity = await get_identity(request)
588
+ if identity is None:
589
+ # authenticated by some other configured means; nothing to scope by
590
+ return UNRESTRICTED
591
+ units = set(identity.get("groups") or [])
592
+ role = reviewer_role(identity)
593
+ if role == "admin" and await admin_read_activity_enabled(request, tenant):
594
+ if record:
595
+ await record_break_glass(request, tenant,
596
+ reviewer_string(identity),
597
+ request.url.path)
598
+ return UNRESTRICTED
599
+ return {"units": units, "agent": None, "role": role,
600
+ "locked": role == "admin" and not units}
601
+
602
+
603
+ def scope_units(scope: dict | None) -> set[str] | None:
604
+ """The `units` filter for ledger queries: None = unrestricted. An
605
+ agent-pinned scope filters by agent, not by unit."""
606
+ if scope is None or scope.get("agent") is not None:
607
+ return None
608
+ return scope["units"]
609
+
610
+
611
+ def visible_to(scope: dict | None, agent_id: str | None,
612
+ unit: str | None) -> bool:
613
+ """Whether one record (an approval, a run) is inside the caller's
614
+ need-to-know scope."""
615
+ if scope is None:
616
+ return True
617
+ if scope.get("agent") is not None:
618
+ return agent_id == scope["agent"]
619
+ return unit is None or unit in scope["units"]
620
+
621
+
622
+ # -- /auth routes ------------------------------------------------------------------
623
+
624
+ router = APIRouter(prefix="/auth")
625
+
626
+
627
+ def _oidc(request: Request) -> OIDC:
628
+ oidc = getattr(request.app.state, "oidc", None)
629
+ if oidc is None:
630
+ raise HTTPException(404, "SSO not configured")
631
+ return oidc
632
+
633
+
634
+ @router.get("/login")
635
+ async def login(request: Request):
636
+ oidc = _oidc(request)
637
+ settings = request.app.state.settings
638
+ state = secrets.token_urlsafe(16)
639
+ nonce = secrets.token_urlsafe(16)
640
+ verifier = secrets.token_urlsafe(48)
641
+ challenge = _b64(hashlib.sha256(verifier.encode()).digest())
642
+ cfg = await oidc.config()
643
+ resp = RedirectResponse(oidc.auth_url_params(cfg, state, nonce, challenge),
644
+ status_code=302)
645
+ from .security import cookie_kwargs
646
+ resp.set_cookie(flow_cookie(settings),
647
+ sign_value(settings.session_secret,
648
+ {"state": state, "nonce": nonce,
649
+ "verifier": verifier, "ts": int(time.time())}),
650
+ max_age=600, **cookie_kwargs(settings))
651
+ return resp
652
+
653
+
654
+ @router.get("/callback")
655
+ async def callback(request: Request, code: str = "", state: str = ""):
656
+ oidc = _oidc(request)
657
+ settings = request.app.state.settings
658
+ flow = unsign_value(settings.session_secret,
659
+ request.cookies.get(flow_cookie(settings), ""))
660
+ if not flow or not state or not hmac.compare_digest(flow["state"], state):
661
+ raise HTTPException(401, "state mismatch (login flow expired?)")
662
+ # the cookie's max_age is advice to the browser; the server must
663
+ # enforce the flow's lifetime itself, or a captured flow cookie
664
+ # (state + nonce + PKCE verifier) replays forever
665
+ if int(time.time()) - int(flow.get("ts", 0)) > 600:
666
+ raise HTTPException(401, "login flow expired, start again")
667
+ tokens = await oidc.exchange_code(code, flow["verifier"])
668
+ claims = await oidc.validate_id_token(tokens.get("id_token", ""),
669
+ flow["nonce"])
670
+ _, cookie = await create_session(request.app.state.pool,
671
+ settings.session_secret,
672
+ settings.session_ttl_s, claims,
673
+ resolve_roles(claims, settings))
674
+ from .security import cookie_kwargs
675
+ kw = cookie_kwargs(settings)
676
+ resp = RedirectResponse("/", status_code=302)
677
+ resp.delete_cookie(flow_cookie(settings), path=kw["path"],
678
+ secure=kw["secure"], httponly=True, samesite="lax")
679
+ resp.set_cookie(session_cookie(settings), cookie,
680
+ max_age=settings.session_ttl_s, **kw)
681
+ return resp
682
+
683
+
684
+ @router.get("/me")
685
+ async def me(request: Request):
686
+ if not _human_plane_on(request):
687
+ raise HTTPException(404, "SSO not configured")
688
+ identity = await get_identity(request)
689
+ if identity is None:
690
+ raise HTTPException(401, "not signed in")
691
+ return {"subject": identity["subject"], "email": identity["email"],
692
+ "name": identity["name"], "roles": identity["roles"],
693
+ "csrf": identity["csrf"]}
694
+
695
+
696
+ @router.post("/logout")
697
+ async def logout(request: Request):
698
+ identity = await get_identity(request)
699
+ if identity is not None:
700
+ header = request.headers.get("X-CSRF-Token", "")
701
+ if not hmac.compare_digest(identity["csrf"], header):
702
+ raise HTTPException(403, "missing or bad CSRF token")
703
+ async with request.app.state.pool.connection() as conn:
704
+ await conn.execute("DELETE FROM sessions WHERE id = %s",
705
+ (identity["sid"],))
706
+ from .security import cookie_kwargs
707
+ kw = cookie_kwargs(request.app.state.settings)
708
+ resp = JSONResponse({"ok": True})
709
+ resp.delete_cookie(session_cookie(request.app.state.settings),
710
+ path=kw["path"], secure=kw["secure"], httponly=True,
711
+ samesite="lax")
712
+ return resp