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.
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 +714 -0
  6. app/chat.py +122 -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 +1042 -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 +213 -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-B_JDe_G2.js +10 -0
  86. app/web-dist/assets/index-J7J-YtwM.css +1 -0
  87. app/web-dist/index.html +24 -0
  88. app/webhooks.py +405 -0
  89. primarypath_server-1.0.0.dist-info/METADATA +35 -0
  90. primarypath_server-1.0.0.dist-info/RECORD +94 -0
  91. primarypath_server-1.0.0.dist-info/WHEEL +5 -0
  92. primarypath_server-1.0.0.dist-info/entry_points.txt +3 -0
  93. primarypath_server-1.0.0.dist-info/licenses/LICENSE +202 -0
  94. primarypath_server-1.0.0.dist-info/top_level.txt +1 -0
app/auth.py ADDED
@@ -0,0 +1,714 @@
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
+ # the registry is the map: issuing a token with a unit registers it
228
+ # there, and PUT /api/agents moves it. The token's own unit is only
229
+ # what it was issued with; stamping it after a move would keep the
230
+ # agent's new work in the old unit (B23)
231
+ unit = await request.app.state.agents.unit_of(
232
+ rec["agent_id"], await resolve_tenant(request))
233
+ if unit is None:
234
+ unit = rec.get("unit")
235
+ return rec["agent_id"], unit
236
+ return DEFAULT_AGENT, None
237
+
238
+
239
+ async def agent_credential(request: Request) -> str:
240
+ """The audit identity of the agent-plane credential presenting this
241
+ request, derived server-side — never typed by the caller. An issued
242
+ service token names the agent it is pinned to AND the revocable
243
+ credential (`agent:<id> (token:<token id> <label>)`), the same
244
+ binding decisions get under issued admin tokens; the env service
245
+ token is single-trust and says so; open dev mode says so too."""
246
+ from .ledger.store import DEFAULT_AGENT
247
+
248
+ rec = await _issued_token_record(request, "service")
249
+ if rec is not None:
250
+ agent = rec.get("agent_id") or DEFAULT_AGENT
251
+ label = rec.get("label")
252
+ bind = f"token:{rec['id']}" + (f" {label}" if label else "")
253
+ return f"agent:{agent} ({bind})"
254
+ if _service_ok(request):
255
+ return f"agent:{DEFAULT_AGENT} (env service token)"
256
+ return f"agent:{DEFAULT_AGENT} (open, no auth configured)"
257
+
258
+
259
+ async def credential_id(request: Request) -> str:
260
+ """A stable, non-secret name for the presenting credential, for
261
+ idempotency fingerprints: issued tokens by id, env tokens by plane,
262
+ sessions by subject, open mode as such."""
263
+ for kind in ("service", "admin"):
264
+ rec = await _issued_token_record(request, kind)
265
+ if rec is not None:
266
+ return f"token:{rec['id']}"
267
+ if _service_ok(request):
268
+ return "env:service"
269
+ if _admin_token_ok(request):
270
+ return "env:admin"
271
+ identity = await get_identity(request)
272
+ if identity is not None:
273
+ return f"user:{identity.get('subject')}"
274
+ return "open"
275
+
276
+
277
+ async def agent_pin(request: Request) -> str | None:
278
+ """The agent this credential is PINNED to, or None for an unpinned
279
+ credential (the env service token, open dev mode). Read from the
280
+ issued token record — never inferred from the agent's name: a token
281
+ pinned to an agent that happens to be called "default" is pinned.
282
+ Ownership checks (idempotent reuse, cancel, redeem) take this, so
283
+ only an unpinned credential has the single-trust posture."""
284
+ rec = await _issued_token_record(request, "service")
285
+ if rec is not None and rec.get("agent_id"):
286
+ return rec["agent_id"]
287
+ return None
288
+
289
+
290
+ async def require_agent_binding(request: Request, agent: str) -> None:
291
+ """Evidence writes that NAME an agent (evals, deploy gates) must not
292
+ outrun the credential: a service token pinned to agent A could
293
+ otherwise record evals for agent B or squat B's gate — forged
294
+ evidence under a real signature. Unpinned tokens (the env token, open
295
+ dev) are the single-trust posture and may write for any agent."""
296
+ rec = await _issued_token_record(request, "service")
297
+ if rec is not None and rec.get("agent_id") and rec["agent_id"] != agent:
298
+ raise HTTPException(
299
+ 403, f"token is bound to agent '{rec['agent_id']}' and cannot "
300
+ f"write evidence for '{agent}'")
301
+
302
+
303
+ def reviewer_role(identity: dict | None) -> str | None:
304
+ if identity is None:
305
+ return None
306
+ roles = identity.get("roles") or []
307
+ return "admin" if "admin" in roles else ("approver" if "approver" in roles else None)
308
+
309
+
310
+ # -- route dependencies -----------------------------------------------------------
311
+
312
+ def _human_plane_on(request: Request) -> bool:
313
+ return bool(request.app.state.settings.oidc_issuer)
314
+
315
+
316
+ def _service_token(request: Request) -> str | None:
317
+ auth = request.headers.get("Authorization", "")
318
+ return auth.removeprefix("Bearer ").strip() if auth.startswith("Bearer ") else None
319
+
320
+
321
+ def _service_ok(request: Request) -> bool:
322
+ expected = request.app.state.settings.service_token
323
+ got = _service_token(request)
324
+ return bool(expected and got and hmac.compare_digest(expected, got))
325
+
326
+
327
+ def _admin_token_ok(request: Request) -> bool:
328
+ expected = request.app.state.settings.admin_token
329
+ got = _service_token(request)
330
+ return bool(expected and got and hmac.compare_digest(expected, got))
331
+
332
+
333
+ async def _issued_token_record(request: Request, kind: str) -> dict | None:
334
+ """The issued (DB) token record of the given kind (F22/F24), or None.
335
+ Kept distinct from the other plane: a service token never verifies as
336
+ admin and vice versa. The token's tenant must match the request's
337
+ resolved tenant, so a token issued for tenant B never authenticates a
338
+ request resolving to A (the F20 seam; inert while resolve_tenant is the
339
+ default tenant)."""
340
+ store = getattr(request.app.state, "tokens", None)
341
+ tok = _service_token(request)
342
+ if store is None or not tok:
343
+ return None
344
+ rec = await store.verify(tok, kind)
345
+ if rec is None or rec["tenant_id"] != await resolve_tenant(request):
346
+ return None
347
+ return rec
348
+
349
+
350
+ async def _issued_token_ok(request: Request, kind: str) -> bool:
351
+ return await _issued_token_record(request, kind) is not None
352
+
353
+
354
+ async def _service_ok_any(request: Request) -> bool:
355
+ return _service_ok(request) or await _issued_token_ok(request, "service")
356
+
357
+
358
+ async def _admin_ok_any(request: Request) -> bool:
359
+ return _admin_token_ok(request) or await _issued_token_ok(request, "admin")
360
+
361
+
362
+ async def _any_auth_configured(request: Request) -> bool:
363
+ """Auth counts as configured the moment an OPERATOR credential exists —
364
+ any env setting, or a console-issued ADMIN token. Without the issued
365
+ clause, a deployment that issued its tokens from the console and set
366
+ no env token would grade as unconfigured and run every open-dev
367
+ fallback wide open, the issued tokens purely decorative.
368
+
369
+ Deliberately asymmetric per plane: an issued SERVICE token closes only
370
+ the agent plane (require_agent), not the operator surfaces — the
371
+ console has no bearer mode, so issuing an agent token from the open
372
+ evaluation console must not brick the very session that issued it.
373
+ Locking the operator planes is what issuing an operator credential
374
+ means; and the bind guard still refuses a network-reachable bind with
375
+ no env auth, so the open evaluation posture stays loopback-only."""
376
+ s = request.app.state.settings
377
+ if s.oidc_issuer or s.service_token or s.admin_token:
378
+ return True
379
+ store = getattr(request.app.state, "tokens", None)
380
+ return store is not None and await store.any_active("admin")
381
+
382
+
383
+ async def is_agent_plane(request: Request) -> bool:
384
+ """Whether this request is authenticated as an agent (a service token,
385
+ or open dev mode where no plane is configured). The execution grant's
386
+ signature is returned only here: it is a bearer capability for one
387
+ consequential action, meant for the executor, not for the console."""
388
+ if not await _any_auth_configured(request):
389
+ return True
390
+ return await _service_ok_any(request)
391
+
392
+
393
+ async def require_agent(request: Request) -> None:
394
+ """Agent-facing endpoints: service token only (never a human session).
395
+ The plane is configured by the env service token OR any issued
396
+ service token."""
397
+ if request.app.state.settings.service_token is None:
398
+ store = getattr(request.app.state, "tokens", None)
399
+ if store is None or not await store.any_active("service"):
400
+ return # plane not configured: open (dev)
401
+ if not await _service_ok_any(request):
402
+ raise HTTPException(401, "service token required")
403
+
404
+
405
+ def _require_role(identity: dict, role: str) -> None:
406
+ if role not in (identity.get("roles") or []):
407
+ raise HTTPException(403, f"{role} role required")
408
+
409
+
410
+ async def require_reader(request: Request) -> None:
411
+ """Read endpoints: an approver session or a service/admin token, once
412
+ auth is on."""
413
+ if not await _any_auth_configured(request):
414
+ return
415
+ if await _service_ok_any(request) or await _admin_ok_any(request):
416
+ return
417
+ if _human_plane_on(request):
418
+ identity = await get_identity(request)
419
+ if identity is not None:
420
+ _require_role(identity, "approver")
421
+ return
422
+ raise HTTPException(401, "authentication required")
423
+
424
+
425
+ async def require_console_reader(request: Request) -> None:
426
+ """Human-console reads (F22 traces): an approver+ session or an ADMIN
427
+ token — NOT the bare agent service token. Traces surface every run's
428
+ reasoning, tool arguments/results, and tenant-wide cost; the low-
429
+ privilege token every governed agent carries must not exfiltrate them.
430
+ (Approval reads keep the service token because the agent polls its own
431
+ wait_for_decision; a trace console has no such need.)"""
432
+ if not await _any_auth_configured(request):
433
+ return
434
+ if await _admin_ok_any(request):
435
+ return
436
+ if _human_plane_on(request):
437
+ identity = await get_identity(request)
438
+ if identity is not None:
439
+ _require_role(identity, "approver")
440
+ return
441
+ raise HTTPException(401, "approver session or admin token required")
442
+
443
+
444
+ async def require_reviewer(request: Request) -> dict | None:
445
+ """Human mutating endpoints: approver session + CSRF when the human
446
+ plane is on.
447
+
448
+ Returns the identity (None in open/dev mode, where the request body
449
+ must carry a reviewer name instead).
450
+
451
+ Token-only deployments (tokens configured, no OIDC) require the ADMIN
452
+ token: reads are token-gated there, so writes must be too, and the
453
+ agent service token must never decide the approvals it created.
454
+ """
455
+ if not _human_plane_on(request):
456
+ if not await _any_auth_configured(request):
457
+ return None # nothing configured: open dev mode
458
+ rec = await _issued_token_record(request, "admin")
459
+ if rec is not None:
460
+ # an ISSUED (per-person) admin token: bind the recorded
461
+ # reviewer to the credential, so token-mode decisions name a
462
+ # revocable identity, not just a typed string
463
+ return {"token_id": rec["id"], "token_label": rec.get("label"),
464
+ "roles": ["admin"]}
465
+ if _admin_token_ok(request):
466
+ return None # env admin token; body names the reviewer
467
+ raise HTTPException(401, "admin token or sign-in required")
468
+ identity = await get_identity(request)
469
+ if identity is None:
470
+ raise HTTPException(401, "sign in required")
471
+ header = request.headers.get("X-CSRF-Token", "")
472
+ if not hmac.compare_digest(identity["csrf"], header):
473
+ raise HTTPException(403, "missing or bad CSRF token")
474
+ _require_role(identity, "approver")
475
+ return identity
476
+
477
+
478
+ async def require_admin(request: Request) -> dict | None:
479
+ """Admin endpoints: an admin session or the dedicated ADMIN token. The
480
+ agent service token never satisfies this — every governed agent carries
481
+ that token, and it must not be able to rotate keys or export the full
482
+ audit log. Open only when no auth at all is configured (dev)."""
483
+ if not await _any_auth_configured(request):
484
+ return None
485
+ if await _admin_ok_any(request):
486
+ return None
487
+ if _human_plane_on(request):
488
+ identity = await get_identity(request)
489
+ if identity is not None:
490
+ header = request.headers.get("X-CSRF-Token", "")
491
+ if (request.method not in ("GET", "HEAD")
492
+ and not hmac.compare_digest(identity["csrf"], header)):
493
+ raise HTTPException(403, "missing or bad CSRF token")
494
+ _require_role(identity, "admin")
495
+ return identity
496
+ raise HTTPException(401, "authentication required")
497
+
498
+
499
+ # -- the need-to-know read gate (F24) -----------------------------------------------
500
+
501
+ UNRESTRICTED = None # scope value: no unit filtering applies
502
+ _BREAK_GLASS_DEDUP_S = 600
503
+
504
+
505
+ async def admin_read_activity_enabled(request: Request, tenant: str) -> bool:
506
+ """Effective admin_can_read_activity: the runtime (event-sourced) toggle
507
+ when one has been set, else the configured default (off — separation of
508
+ duties ships closed)."""
509
+ async with request.app.state.pool.connection() as conn:
510
+ cur = await conn.execute(
511
+ "SELECT value FROM config_view WHERE tenant_id = %s "
512
+ "AND key = 'admin_can_read_activity'", (tenant,))
513
+ row = await cur.fetchone()
514
+ if row is not None:
515
+ return bool(row[0])
516
+ return request.app.state.settings.admin_can_read_activity
517
+
518
+
519
+ async def record_break_glass(request: Request, tenant: str, actor: str,
520
+ what: str) -> None:
521
+ """An admin reading activity under admin_can_read_activity is itself a
522
+ chained governance event — break-glass is provable. Deduped in memory
523
+ per (tenant, actor) so a polling console appends one record per window,
524
+ not one per request."""
525
+ dedup = getattr(request.app.state, "_break_glass_seen", None)
526
+ if dedup is None:
527
+ dedup = request.app.state._break_glass_seen = {}
528
+ now = time.time()
529
+ last = dedup.get((tenant, actor))
530
+ if last is not None and now - last < _BREAK_GLASS_DEDUP_S:
531
+ return
532
+ dedup[(tenant, actor)] = now
533
+ await request.app.state.ledger.append(
534
+ "admin_action",
535
+ {"action": "read_activity", "by": actor, "detail": what},
536
+ tenant=tenant)
537
+
538
+
539
+ async def activity_scope(request: Request, record: bool = True) -> dict | None:
540
+ """What activity this caller may READ. Visibility follows the unit,
541
+ never the admin role (F24).
542
+
543
+ Returns UNRESTRICTED (None) when no gate applies — open dev mode, the
544
+ single-trust env tokens without a human plane, or an admin under an
545
+ enabled (and audited) admin_can_read_activity. Otherwise:
546
+
547
+ {"units": set[str], # agents in these units are visible
548
+ "agent": str|None, # an issued service token pinned to its agent
549
+ "role": str|None, # 'admin' drives the console's locked state
550
+ "locked": bool} # admin barred by separation of duties
551
+
552
+ Agents with NO unit assigned stay visible to any authorized reader
553
+ (enforced in the queries) — need-to-know starts when units are mapped,
554
+ so pre-F24 installs do not go dark.
555
+
556
+ The caller is assumed already authenticated (require_reader et al.);
557
+ this only narrows what an authenticated reader sees.
558
+ """
559
+ if not await _any_auth_configured(request):
560
+ return UNRESTRICTED
561
+ tenant = await resolve_tenant(request)
562
+
563
+ # agent plane: an issued token pinned to an agent reads only that agent
564
+ # (its own queue and decisions); tokens without a pin are the
565
+ # single-trust dev posture and read unrestricted
566
+ svc = await _issued_token_record(request, "service")
567
+ if svc is not None:
568
+ if svc.get("agent_id"):
569
+ return {"units": set(), "agent": svc["agent_id"], "role": None,
570
+ "locked": False}
571
+ return UNRESTRICTED
572
+ if _service_ok(request):
573
+ return UNRESTRICTED
574
+
575
+ # operator plane without a human plane configured is the same
576
+ # single-trust posture (dev/tests); with OIDC on, the admin token is an
577
+ # operator credential and separation of duties applies to it
578
+ if await _admin_ok_any(request):
579
+ if not _human_plane_on(request):
580
+ return UNRESTRICTED
581
+ if await admin_read_activity_enabled(request, tenant):
582
+ if record:
583
+ await record_break_glass(request, tenant, "admin-token",
584
+ request.url.path)
585
+ return UNRESTRICTED
586
+ return {"units": set(), "agent": None, "role": "admin",
587
+ "locked": True}
588
+
589
+ identity = await get_identity(request)
590
+ if identity is None:
591
+ # authenticated by some other configured means; nothing to scope by
592
+ return UNRESTRICTED
593
+ units = set(identity.get("groups") or [])
594
+ role = reviewer_role(identity)
595
+ if role == "admin" and await admin_read_activity_enabled(request, tenant):
596
+ if record:
597
+ await record_break_glass(request, tenant,
598
+ reviewer_string(identity),
599
+ request.url.path)
600
+ return UNRESTRICTED
601
+ return {"units": units, "agent": None, "role": role,
602
+ "locked": role == "admin" and not units}
603
+
604
+
605
+ def scope_units(scope: dict | None) -> set[str] | None:
606
+ """The `units` filter for ledger queries: None = unrestricted. An
607
+ agent-pinned scope filters by agent, not by unit."""
608
+ if scope is None or scope.get("agent") is not None:
609
+ return None
610
+ return scope["units"]
611
+
612
+
613
+ def visible_to(scope: dict | None, agent_id: str | None,
614
+ unit: str | None) -> bool:
615
+ """Whether one record (an approval, a run) is inside the caller's
616
+ need-to-know scope."""
617
+ if scope is None:
618
+ return True
619
+ if scope.get("agent") is not None:
620
+ return agent_id == scope["agent"]
621
+ return unit is None or unit in scope["units"]
622
+
623
+
624
+ # -- /auth routes ------------------------------------------------------------------
625
+
626
+ router = APIRouter(prefix="/auth")
627
+
628
+
629
+ def _oidc(request: Request) -> OIDC:
630
+ oidc = getattr(request.app.state, "oidc", None)
631
+ if oidc is None:
632
+ raise HTTPException(404, "SSO not configured")
633
+ return oidc
634
+
635
+
636
+ @router.get("/login")
637
+ async def login(request: Request):
638
+ oidc = _oidc(request)
639
+ settings = request.app.state.settings
640
+ state = secrets.token_urlsafe(16)
641
+ nonce = secrets.token_urlsafe(16)
642
+ verifier = secrets.token_urlsafe(48)
643
+ challenge = _b64(hashlib.sha256(verifier.encode()).digest())
644
+ cfg = await oidc.config()
645
+ resp = RedirectResponse(oidc.auth_url_params(cfg, state, nonce, challenge),
646
+ status_code=302)
647
+ from .security import cookie_kwargs
648
+ resp.set_cookie(flow_cookie(settings),
649
+ sign_value(settings.session_secret,
650
+ {"state": state, "nonce": nonce,
651
+ "verifier": verifier, "ts": int(time.time())}),
652
+ max_age=600, **cookie_kwargs(settings))
653
+ return resp
654
+
655
+
656
+ @router.get("/callback")
657
+ async def callback(request: Request, code: str = "", state: str = ""):
658
+ oidc = _oidc(request)
659
+ settings = request.app.state.settings
660
+ flow = unsign_value(settings.session_secret,
661
+ request.cookies.get(flow_cookie(settings), ""))
662
+ if not flow or not state or not hmac.compare_digest(flow["state"], state):
663
+ raise HTTPException(401, "state mismatch (login flow expired?)")
664
+ # the cookie's max_age is advice to the browser; the server must
665
+ # enforce the flow's lifetime itself, or a captured flow cookie
666
+ # (state + nonce + PKCE verifier) replays forever
667
+ if int(time.time()) - int(flow.get("ts", 0)) > 600:
668
+ raise HTTPException(401, "login flow expired, start again")
669
+ tokens = await oidc.exchange_code(code, flow["verifier"])
670
+ claims = await oidc.validate_id_token(tokens.get("id_token", ""),
671
+ flow["nonce"])
672
+ _, cookie = await create_session(request.app.state.pool,
673
+ settings.session_secret,
674
+ settings.session_ttl_s, claims,
675
+ resolve_roles(claims, settings))
676
+ from .security import cookie_kwargs
677
+ kw = cookie_kwargs(settings)
678
+ resp = RedirectResponse("/", status_code=302)
679
+ resp.delete_cookie(flow_cookie(settings), path=kw["path"],
680
+ secure=kw["secure"], httponly=True, samesite="lax")
681
+ resp.set_cookie(session_cookie(settings), cookie,
682
+ max_age=settings.session_ttl_s, **kw)
683
+ return resp
684
+
685
+
686
+ @router.get("/me")
687
+ async def me(request: Request):
688
+ if not _human_plane_on(request):
689
+ raise HTTPException(404, "SSO not configured")
690
+ identity = await get_identity(request)
691
+ if identity is None:
692
+ raise HTTPException(401, "not signed in")
693
+ return {"subject": identity["subject"], "email": identity["email"],
694
+ "name": identity["name"], "roles": identity["roles"],
695
+ "csrf": identity["csrf"]}
696
+
697
+
698
+ @router.post("/logout")
699
+ async def logout(request: Request):
700
+ identity = await get_identity(request)
701
+ if identity is not None:
702
+ header = request.headers.get("X-CSRF-Token", "")
703
+ if not hmac.compare_digest(identity["csrf"], header):
704
+ raise HTTPException(403, "missing or bad CSRF token")
705
+ async with request.app.state.pool.connection() as conn:
706
+ await conn.execute("DELETE FROM sessions WHERE id = %s",
707
+ (identity["sid"],))
708
+ from .security import cookie_kwargs
709
+ kw = cookie_kwargs(request.app.state.settings)
710
+ resp = JSONResponse({"ok": True})
711
+ resp.delete_cookie(session_cookie(request.app.state.settings),
712
+ path=kw["path"], secure=kw["secure"], httponly=True,
713
+ samesite="lax")
714
+ return resp