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 +61 -0
- agentadmit/auth.py +494 -0
- agentadmit/cli.py +232 -0
- agentadmit/config.py +230 -0
- agentadmit/exceptions.py +79 -0
- agentadmit/integrations/__init__.py +5 -0
- agentadmit/integrations/django_integration.py +446 -0
- agentadmit/integrations/flask_integration.py +392 -0
- agentadmit/keys.py +42 -0
- agentadmit/middleware.py +185 -0
- agentadmit/models.py +113 -0
- agentadmit/routes.py +432 -0
- agentadmit/storage.py +282 -0
- agentadmit-1.0.0.dist-info/METADATA +286 -0
- agentadmit-1.0.0.dist-info/RECORD +19 -0
- agentadmit-1.0.0.dist-info/WHEEL +5 -0
- agentadmit-1.0.0.dist-info/entry_points.txt +2 -0
- agentadmit-1.0.0.dist-info/licenses/LICENSE +56 -0
- agentadmit-1.0.0.dist-info/top_level.txt +1 -0
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
|
+
)
|