agentadmit 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.
agentadmit/__init__.py ADDED
@@ -0,0 +1,61 @@
1
+ """
2
+ AgentAdmit SDK for Python
3
+ =========================
4
+
5
+ User-mediated AI agent authorization. Plug-and-play for any FastAPI app.
6
+
7
+ Quick Start:
8
+ from agentadmit import AgentAdmitMiddleware, require_scope, require_scope_if_agent
9
+
10
+ app.add_middleware(AgentAdmitMiddleware, config_path="agentadmit.yaml")
11
+
12
+ @app.get("/api/orders")
13
+ async def get_orders(
14
+ auth_ctx=Depends(get_current_user_or_agent),
15
+ _scope=Depends(require_scope_if_agent("read:orders")),
16
+ ):
17
+ ...
18
+ """
19
+
20
+ __version__ = "0.1.0"
21
+
22
+ from agentadmit.config import AgentAdmitConfig, load_config
23
+ from agentadmit.middleware import AgentAdmitMiddleware
24
+ from agentadmit.auth import (
25
+ get_agentadmit_user,
26
+ get_current_user_or_agent,
27
+ require_scope,
28
+ require_scope_if_agent,
29
+ log_agent_access,
30
+ check_connection_cap,
31
+ )
32
+ from agentadmit.routes import create_agentadmit_router
33
+ # keys.py is deprecated — AgentAdmit is a hosted service, no local keys needed
34
+ from agentadmit.exceptions import (
35
+ AgentAdmitError,
36
+ InvalidTokenError,
37
+ InsufficientScopeError,
38
+ ConnectionRevokedError,
39
+ ConnectionLimitError,
40
+ ConfigurationError,
41
+ )
42
+
43
+ __all__ = [
44
+ "AgentAdmitMiddleware",
45
+ "AgentAdmitConfig",
46
+ "load_config",
47
+ "get_agentadmit_user",
48
+ "get_current_user_or_agent",
49
+ "require_scope",
50
+ "require_scope_if_agent",
51
+ "log_agent_access",
52
+ "check_connection_cap",
53
+ "create_agentadmit_router",
54
+
55
+ "AgentAdmitError",
56
+ "InvalidTokenError",
57
+ "InsufficientScopeError",
58
+ "ConnectionRevokedError",
59
+ "ConnectionLimitError",
60
+ "ConfigurationError",
61
+ ]
agentadmit/auth.py ADDED
@@ -0,0 +1,494 @@
1
+ """
2
+ agentadmit.auth
3
+ ---------------
4
+ Token validation, scope enforcement, and audit logging.
5
+
6
+ Generalized from TrainerTracer's agentadmit_auth.py.
7
+ All app-specific references removed — works with any FastAPI app.
8
+ """
9
+
10
+ import logging
11
+ import random
12
+ import time
13
+ from datetime import datetime
14
+ from typing import Callable, Optional
15
+
16
+ import jwt
17
+ from fastapi import Depends, HTTPException, Request
18
+ from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
19
+
20
+ from agentadmit.config import get_config
21
+ from agentadmit.keys import load_public_key
22
+ from agentadmit.exceptions import (
23
+ InvalidTokenError,
24
+ InsufficientScopeError,
25
+ ConnectionRevokedError,
26
+ ConnectionLimitError,
27
+ ConfigurationError,
28
+ RateLimitError,
29
+ )
30
+
31
+ logger = logging.getLogger(__name__)
32
+
33
+ # Bearer token extractor
34
+ security = HTTPBearer(auto_error=False)
35
+
36
+ # Storage backend reference — set by middleware during startup
37
+ _storage = None
38
+
39
+ # App's user verification function — set by middleware during startup
40
+ # Signature: (token: str) -> str (returns user_id)
41
+ _verify_user_token: Optional[Callable] = None
42
+
43
+
44
+ def _set_storage(storage):
45
+ """Called by middleware to inject the storage backend."""
46
+ global _storage
47
+ _storage = storage
48
+
49
+
50
+ def _set_user_verifier(fn: Callable):
51
+ """Called by middleware to inject the app's user token verification function."""
52
+ global _verify_user_token
53
+ _verify_user_token = fn
54
+
55
+
56
+ def _get_storage():
57
+ """Get the storage backend. Raises if not initialized."""
58
+ if _storage is None:
59
+ raise ConfigurationError("AgentAdmit storage not initialized. Did you add AgentAdmitMiddleware?")
60
+ return _storage
61
+
62
+
63
+ # ---------------------------------------------------------------------------
64
+ # _introspect_with_retry — HTTP call with 429 exponential backoff + jitter
65
+ # ---------------------------------------------------------------------------
66
+
67
+ def _introspect_with_retry(
68
+ url: str,
69
+ token: str,
70
+ app_id: str,
71
+ api_key: str,
72
+ timeout: int = 5,
73
+ max_retries: int = 3,
74
+ ) -> "requests.Response":
75
+ """
76
+ POST to the AgentAdmit introspection endpoint with automatic 429 retry.
77
+
78
+ Retry policy:
79
+ - Initial delay: 1 second
80
+ - Each retry doubles the delay (exponential backoff), capped at 30 seconds
81
+ - Each delay adds 0–500 ms of random jitter
82
+ - Honors Retry-After header if present (overrides computed delay)
83
+ - After max_retries exhausted on 429, raises RateLimitError
84
+
85
+ Returns the successful Response object (status 200 or non-429 error).
86
+ """
87
+ import requests as _requests
88
+
89
+ headers = {
90
+ "Authorization": f"Bearer {api_key}",
91
+ "Content-Type": "application/json",
92
+ }
93
+ payload = {"token": token}
94
+
95
+ delay = 1.0 # seconds — initial backoff
96
+
97
+ for attempt in range(max_retries + 1):
98
+ try:
99
+ response = _requests.post(url, headers=headers, json=payload, timeout=timeout)
100
+ except _requests.exceptions.RequestException as exc:
101
+ logger.error("AgentAdmit introspection failed (network): %s", exc)
102
+ raise HTTPException(
103
+ status_code=502,
104
+ detail={
105
+ "error": "introspection_failed",
106
+ "error_description": "Could not reach AgentAdmit verification service",
107
+ },
108
+ )
109
+
110
+ if response.status_code != 429:
111
+ return response
112
+
113
+ # --- 429 handling ---
114
+ # Parse rate-limit headers for error context
115
+ rl_limit = _parse_int_header(response, "X-RateLimit-Limit")
116
+ rl_remaining = _parse_int_header(response, "X-RateLimit-Remaining")
117
+ rl_reset = _parse_int_header(response, "X-RateLimit-Reset")
118
+ retry_after_hdr = _parse_float_header(response, "Retry-After")
119
+
120
+ if attempt >= max_retries:
121
+ # All retries exhausted — raise RateLimitError
122
+ raise RateLimitError(
123
+ message=(
124
+ f"AgentAdmit rate limit exceeded. "
125
+ f"Max retries ({max_retries}) exhausted."
126
+ ),
127
+ retry_after=retry_after_hdr,
128
+ limit=rl_limit,
129
+ remaining=rl_remaining,
130
+ reset=rl_reset,
131
+ )
132
+
133
+ # Compute wait time: Retry-After beats exponential backoff
134
+ wait = retry_after_hdr if retry_after_hdr is not None else min(delay, 30.0)
135
+ jitter = random.uniform(0, 0.5) # 0–500 ms
136
+ wait_total = wait + jitter
137
+
138
+ logger.warning(
139
+ "AgentAdmit introspection rate-limited (attempt %d/%d). "
140
+ "Retrying in %.2fs (delay=%.1fs, jitter=%.3fs).",
141
+ attempt + 1,
142
+ max_retries,
143
+ wait_total,
144
+ wait,
145
+ jitter,
146
+ )
147
+
148
+ time.sleep(wait_total)
149
+ delay = min(delay * 2, 30.0) # double for next attempt, cap at 30s
150
+
151
+ # Should never be reached
152
+ raise RuntimeError("Unexpected exit from retry loop") # pragma: no cover
153
+
154
+
155
+ def _parse_int_header(response: "requests.Response", name: str) -> Optional[int]:
156
+ """Parse an integer HTTP response header, returning None if missing or invalid."""
157
+ val = response.headers.get(name)
158
+ if val is None:
159
+ return None
160
+ try:
161
+ return int(val)
162
+ except (ValueError, TypeError):
163
+ return None
164
+
165
+
166
+ def _parse_float_header(response: "requests.Response", name: str) -> Optional[float]:
167
+ """Parse a float HTTP response header, returning None if missing or invalid."""
168
+ val = response.headers.get(name)
169
+ if val is None:
170
+ return None
171
+ try:
172
+ return float(val)
173
+ except (ValueError, TypeError):
174
+ return None
175
+
176
+
177
+ # ---------------------------------------------------------------------------
178
+ # get_agentadmit_user — primary agent token validation
179
+ # ---------------------------------------------------------------------------
180
+
181
+ def get_agentadmit_user(
182
+ credentials: HTTPAuthorizationCredentials = Depends(security),
183
+ ) -> dict:
184
+ """
185
+ Validates an AgentAdmit access token (ag_at_ prefixed RS256 JWT).
186
+
187
+ Validation steps:
188
+ 1. Authorization header present
189
+ 2. Token starts with ag_at_ prefix
190
+ 3. JWT signature valid (RS256)
191
+ 4. JWT not expired
192
+ 5. Audience matches
193
+ 6. Connection record exists with status == "active"
194
+ 7. User account exists
195
+
196
+ Returns:
197
+ {
198
+ "user": <user document>,
199
+ "connection": <connection document>,
200
+ "scopes": <list[str]>,
201
+ }
202
+ """
203
+ config = get_config()
204
+ storage = _get_storage()
205
+
206
+ if credentials is None:
207
+ raise HTTPException(
208
+ status_code=401,
209
+ detail={"error": "invalid_token", "error_description": "Authorization header is required"},
210
+ )
211
+
212
+ token = credentials.credentials
213
+
214
+ # Prefix check
215
+ if not token.startswith(config.token_prefix_access):
216
+ raise HTTPException(
217
+ status_code=401,
218
+ detail={"error": "invalid_token", "error_description": f"Not an AgentAdmit access token (expected {config.token_prefix_access} prefix)"},
219
+ )
220
+
221
+ raw_token = token[len(config.token_prefix_access):]
222
+
223
+ # MANDATORY INTROSPECTION — validate via AgentAdmit hosted service
224
+ # No local JWT decode. Every verification call goes through AgentAdmit.
225
+ # This is how we meter usage, seed the marketplace, and enforce billing.
226
+
227
+ max_retries = getattr(config, "max_retries", 3)
228
+ try:
229
+ verify_response = _introspect_with_retry(
230
+ url=config.agentadmit_verify_url,
231
+ token=token,
232
+ app_id=config.app_id,
233
+ api_key=config.api_key,
234
+ timeout=5,
235
+ max_retries=max_retries,
236
+ )
237
+ except RateLimitError:
238
+ raise # Let RateLimitError propagate as-is for caller to handle
239
+
240
+ if verify_response.status_code == 401:
241
+ raise HTTPException(
242
+ status_code=401,
243
+ detail=verify_response.json() if verify_response.headers.get("content-type", "").startswith("application/json") else {"error": "invalid_token", "error_description": "Token validation failed"},
244
+ )
245
+
246
+ if verify_response.status_code != 200:
247
+ logger.error("AgentAdmit introspection returned %d: %s", verify_response.status_code, verify_response.text)
248
+ raise HTTPException(
249
+ status_code=502,
250
+ detail={"error": "introspection_failed", "error_description": f"Verification service returned {verify_response.status_code}"},
251
+ )
252
+
253
+ introspection_data = verify_response.json()
254
+
255
+ # Check active flag (RFC 7662 introspection pattern).
256
+ # The verify endpoint returns {active: false} with HTTP 200 for invalid/
257
+ # expired/revoked tokens. Without this check, we'd read empty scopes.
258
+ if not introspection_data.get("active"):
259
+ reason = introspection_data.get("error", "invalid_token")
260
+ raise HTTPException(
261
+ status_code=401,
262
+ detail={"error": "invalid_token", "error_description": f"Token is not active: {reason}"},
263
+ )
264
+
265
+ # Extract validated data from introspection response
266
+ scopes = introspection_data.get("scopes", [])
267
+ user_id = introspection_data.get("user_id")
268
+ connection_id = introspection_data.get("connection_id")
269
+
270
+ if not user_id:
271
+ raise HTTPException(
272
+ status_code=401,
273
+ detail={"error": "invalid_token", "error_description": "Introspection returned no user"},
274
+ )
275
+
276
+ # User lookup from app's local database
277
+ user = storage.get_user(user_id, config.user_lookup_field) if storage else None
278
+ connection = {"connection_id": connection_id, "scopes": scopes, "agent_label": introspection_data.get("agent_label", "Unknown Agent")}
279
+
280
+ return {"user": user or {"user_id": user_id}, "connection": connection, "scopes": scopes}
281
+
282
+
283
+ # ---------------------------------------------------------------------------
284
+ # require_scope — strict scope enforcement (agent-only endpoints)
285
+ # ---------------------------------------------------------------------------
286
+
287
+ def require_scope(scope: str):
288
+ """
289
+ FastAPI dependency factory. Checks the agent's granted scopes include
290
+ the required scope, then logs access.
291
+
292
+ Usage:
293
+ @app.get("/api/orders")
294
+ async def get_orders(agent_ctx=Depends(require_scope("read:orders"))):
295
+ user = agent_ctx["user"]
296
+ ...
297
+ """
298
+ def scope_checker(
299
+ agent_ctx: dict = Depends(get_agentadmit_user),
300
+ ) -> dict:
301
+ granted_scopes = agent_ctx.get("scopes", [])
302
+
303
+ if scope not in granted_scopes:
304
+ raise HTTPException(
305
+ status_code=403,
306
+ detail={
307
+ "error": "insufficient_scope",
308
+ "required_scope": scope,
309
+ "granted_scopes": granted_scopes,
310
+ "message": f"This action requires '{scope}' scope. The user can grant additional scopes through AgentAdmit settings.",
311
+ },
312
+ )
313
+
314
+ log_agent_access(agent_ctx=agent_ctx, scope_used=scope)
315
+ return agent_ctx
316
+
317
+ return scope_checker
318
+
319
+
320
+ # ---------------------------------------------------------------------------
321
+ # require_scope_if_agent — dual-token scope enforcement
322
+ # ---------------------------------------------------------------------------
323
+
324
+ def require_scope_if_agent(scope: str):
325
+ """
326
+ FastAPI dependency factory for dual-token endpoints.
327
+
328
+ - Regular user JWT → passes silently (no scope enforcement)
329
+ - AgentAdmit token (ag_at_) → validates and enforces scope
330
+
331
+ Usage:
332
+ @app.get("/api/orders")
333
+ async def get_orders(
334
+ auth_ctx=Depends(get_current_user_or_agent),
335
+ _scope=Depends(require_scope_if_agent("read:orders")),
336
+ ):
337
+ user = auth_ctx["user"]
338
+ ...
339
+ """
340
+ config = get_config()
341
+
342
+ def scope_checker(
343
+ credentials: HTTPAuthorizationCredentials = Depends(security),
344
+ ) -> Optional[dict]:
345
+ if credentials is None:
346
+ return None
347
+
348
+ token = credentials.credentials
349
+
350
+ # Not an agent token — regular user, no scope enforcement
351
+ if not token.startswith(config.token_prefix_access):
352
+ return None
353
+
354
+ # Agent token — validate and enforce
355
+ agent_ctx = get_agentadmit_user(credentials)
356
+ granted_scopes = agent_ctx.get("scopes", [])
357
+
358
+ if scope not in granted_scopes:
359
+ raise HTTPException(
360
+ status_code=403,
361
+ detail={
362
+ "error": "insufficient_scope",
363
+ "required_scope": scope,
364
+ "granted_scopes": granted_scopes,
365
+ "message": f"This action requires '{scope}' scope. The user can grant additional scopes through AgentAdmit settings.",
366
+ },
367
+ )
368
+
369
+ log_agent_access(agent_ctx=agent_ctx, scope_used=scope)
370
+ return agent_ctx
371
+
372
+ return scope_checker
373
+
374
+
375
+ # ---------------------------------------------------------------------------
376
+ # get_current_user_or_agent — unified dual-token resolver
377
+ # ---------------------------------------------------------------------------
378
+
379
+ def get_current_user_or_agent(
380
+ credentials: HTTPAuthorizationCredentials = Depends(security),
381
+ ) -> dict:
382
+ """
383
+ Accepts both regular app JWTs and AgentAdmit tokens.
384
+
385
+ - Regular JWT → auth_type="user", scopes=["*"]
386
+ - AgentAdmit token → auth_type="agent", scopes=[granted list]
387
+
388
+ The app must provide a user token verifier via AgentAdmitMiddleware(verify_user_token=fn).
389
+ """
390
+ config = get_config()
391
+
392
+ if credentials is None:
393
+ raise HTTPException(status_code=401, detail="Not authenticated")
394
+
395
+ token = credentials.credentials
396
+
397
+ if token.startswith(config.token_prefix_access):
398
+ # AgentAdmit path
399
+ agent_ctx = get_agentadmit_user(credentials)
400
+ return {"auth_type": "agent", **agent_ctx}
401
+ else:
402
+ # Regular user path — delegate to app's verifier
403
+ if _verify_user_token is None:
404
+ raise ConfigurationError(
405
+ "No user token verifier configured. "
406
+ "Pass verify_user_token to AgentAdmitMiddleware."
407
+ )
408
+
409
+ try:
410
+ user_id = _verify_user_token(token)
411
+ except Exception:
412
+ raise HTTPException(status_code=401, detail="Invalid or expired authentication token")
413
+
414
+ storage = _get_storage()
415
+ user = storage.get_user(user_id, config.user_lookup_field)
416
+ if not user:
417
+ raise HTTPException(status_code=404, detail="User not found")
418
+
419
+ return {
420
+ "auth_type": "user",
421
+ "user": user,
422
+ "scopes": ["*"],
423
+ "connection": None,
424
+ }
425
+
426
+
427
+ # ---------------------------------------------------------------------------
428
+ # log_agent_access — per-request audit trail
429
+ # ---------------------------------------------------------------------------
430
+
431
+ def log_agent_access(
432
+ agent_ctx: dict,
433
+ scope_used: str,
434
+ resource: str = "",
435
+ method: str = "",
436
+ status_code: int = 200,
437
+ ) -> None:
438
+ """Write a structured audit entry. Errors are swallowed — must not break API calls."""
439
+ try:
440
+ storage = _get_storage()
441
+ connection = agent_ctx.get("connection") or {}
442
+ user = agent_ctx.get("user") or {}
443
+ config = get_config()
444
+
445
+ entry = {
446
+ "timestamp": datetime.utcnow(),
447
+ "connection_id": connection.get("connection_id", "unknown"),
448
+ "user_id": user.get(config.user_lookup_field, "unknown"),
449
+ "scope_used": scope_used,
450
+ "resource": resource,
451
+ "method": method,
452
+ "status_code": status_code,
453
+ "agent_label": connection.get("agent_label", "Unknown Agent"),
454
+ "agent_id": connection.get("agent_id"),
455
+ }
456
+
457
+ storage.log_access(entry)
458
+
459
+ except Exception as exc:
460
+ logger.error("Failed to write AgentAdmit audit log: %s", exc)
461
+
462
+
463
+ # ---------------------------------------------------------------------------
464
+ # check_connection_cap — tier enforcement for new connections
465
+ # ---------------------------------------------------------------------------
466
+
467
+ def check_connection_cap(user_id: str, tier: str) -> None:
468
+ """
469
+ Check if user is at their connection hard cap before allowing a new connection.
470
+
471
+ Raises HTTPException 429 if at limit with hard_cap=True.
472
+ """
473
+ from agentadmit.config import get_tier_limits as _get_tier_limits
474
+
475
+ limits = _get_tier_limits(tier)
476
+ storage = _get_storage()
477
+
478
+ if not limits.get("hard_cap", False):
479
+ return
480
+
481
+ connections_limit = limits["connections_limit"]
482
+ active_count = storage.count_active_connections(user_id)
483
+
484
+ if active_count >= connections_limit:
485
+ raise HTTPException(
486
+ status_code=429,
487
+ detail={
488
+ "error": "connection_limit_reached",
489
+ "error_description": f"Your {tier} plan allows a maximum of {connections_limit} active agent connections.",
490
+ "connections_used": active_count,
491
+ "connections_limit": connections_limit,
492
+ "tier": tier,
493
+ },
494
+ )