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/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
|