aegra-api 0.1.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.
- aegra_api/__init__.py +3 -0
- aegra_api/api/__init__.py +1 -0
- aegra_api/api/assistants.py +235 -0
- aegra_api/api/runs.py +1110 -0
- aegra_api/api/store.py +200 -0
- aegra_api/api/threads.py +761 -0
- aegra_api/config.py +204 -0
- aegra_api/constants.py +5 -0
- aegra_api/core/__init__.py +0 -0
- aegra_api/core/app_loader.py +91 -0
- aegra_api/core/auth_ctx.py +65 -0
- aegra_api/core/auth_deps.py +186 -0
- aegra_api/core/auth_handlers.py +248 -0
- aegra_api/core/auth_middleware.py +331 -0
- aegra_api/core/database.py +123 -0
- aegra_api/core/health.py +131 -0
- aegra_api/core/orm.py +165 -0
- aegra_api/core/route_merger.py +69 -0
- aegra_api/core/serializers/__init__.py +7 -0
- aegra_api/core/serializers/base.py +22 -0
- aegra_api/core/serializers/general.py +54 -0
- aegra_api/core/serializers/langgraph.py +102 -0
- aegra_api/core/sse.py +178 -0
- aegra_api/main.py +303 -0
- aegra_api/middleware/__init__.py +4 -0
- aegra_api/middleware/double_encoded_json.py +74 -0
- aegra_api/middleware/logger_middleware.py +95 -0
- aegra_api/models/__init__.py +76 -0
- aegra_api/models/assistants.py +81 -0
- aegra_api/models/auth.py +62 -0
- aegra_api/models/enums.py +29 -0
- aegra_api/models/errors.py +29 -0
- aegra_api/models/runs.py +124 -0
- aegra_api/models/store.py +67 -0
- aegra_api/models/threads.py +152 -0
- aegra_api/observability/__init__.py +1 -0
- aegra_api/observability/base.py +88 -0
- aegra_api/observability/otel.py +133 -0
- aegra_api/observability/setup.py +27 -0
- aegra_api/observability/targets/__init__.py +11 -0
- aegra_api/observability/targets/base.py +18 -0
- aegra_api/observability/targets/langfuse.py +33 -0
- aegra_api/observability/targets/otlp.py +38 -0
- aegra_api/observability/targets/phoenix.py +24 -0
- aegra_api/services/__init__.py +0 -0
- aegra_api/services/assistant_service.py +569 -0
- aegra_api/services/base_broker.py +59 -0
- aegra_api/services/broker.py +141 -0
- aegra_api/services/event_converter.py +157 -0
- aegra_api/services/event_store.py +196 -0
- aegra_api/services/graph_streaming.py +433 -0
- aegra_api/services/langgraph_service.py +456 -0
- aegra_api/services/streaming_service.py +362 -0
- aegra_api/services/thread_state_service.py +128 -0
- aegra_api/settings.py +124 -0
- aegra_api/utils/__init__.py +3 -0
- aegra_api/utils/assistants.py +23 -0
- aegra_api/utils/run_utils.py +60 -0
- aegra_api/utils/setup_logging.py +122 -0
- aegra_api/utils/sse_utils.py +26 -0
- aegra_api/utils/status_compat.py +57 -0
- aegra_api-0.1.0.dist-info/METADATA +244 -0
- aegra_api-0.1.0.dist-info/RECORD +64 -0
- aegra_api-0.1.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
"""Authorization handler support for @auth.on.* decorators.
|
|
2
|
+
|
|
3
|
+
This module provides integration with authorization handlers,
|
|
4
|
+
allowing users to define fine-grained access control rules using @auth.on.*
|
|
5
|
+
decorators in their auth.py files.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from fastapi import HTTPException
|
|
11
|
+
from langgraph_sdk import Auth
|
|
12
|
+
from langgraph_sdk.auth.types import AuthContext as LangGraphAuthContext
|
|
13
|
+
|
|
14
|
+
from aegra_api.core.auth_middleware import get_auth_instance
|
|
15
|
+
from aegra_api.models.auth import User
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class AuthContextWrapper:
|
|
19
|
+
"""Wrapper to convert Aegra User model to AuthContext.
|
|
20
|
+
|
|
21
|
+
AuthContext expects a BaseUser-compatible object. Our User model
|
|
22
|
+
implements the BaseUser protocol (identity, permissions, display_name, __getitem__),
|
|
23
|
+
so we can use it directly after ensuring compatibility.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def __init__(
|
|
27
|
+
self,
|
|
28
|
+
user: User,
|
|
29
|
+
resource: str,
|
|
30
|
+
action: str,
|
|
31
|
+
):
|
|
32
|
+
"""Initialize auth context wrapper.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
user: Authenticated user from Aegra's User model
|
|
36
|
+
resource: Resource being accessed (e.g., "threads", "assistants")
|
|
37
|
+
action: Action being performed (e.g., "create", "read", "update")
|
|
38
|
+
"""
|
|
39
|
+
self.user = user
|
|
40
|
+
self.resource = resource
|
|
41
|
+
self.action = action
|
|
42
|
+
self.permissions = user.permissions or []
|
|
43
|
+
|
|
44
|
+
def to_langgraph_context(self) -> LangGraphAuthContext:
|
|
45
|
+
"""Convert to LangGraph AuthContext.
|
|
46
|
+
|
|
47
|
+
Our User model implements the BaseUser protocol (identity, permissions,
|
|
48
|
+
display_name, __getitem__, __contains__, __iter__), so it's compatible
|
|
49
|
+
with LangGraph's AuthContext.
|
|
50
|
+
|
|
51
|
+
Returns:
|
|
52
|
+
AuthContext instance compatible with @auth.on handlers
|
|
53
|
+
"""
|
|
54
|
+
return LangGraphAuthContext(
|
|
55
|
+
user=self.user, # Our User model implements BaseUser protocol
|
|
56
|
+
resource=self.resource, # type: ignore
|
|
57
|
+
action=self.action, # type: ignore
|
|
58
|
+
permissions=self.permissions,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
async def handle_event(
|
|
63
|
+
ctx: AuthContextWrapper | None,
|
|
64
|
+
value: dict[str, Any],
|
|
65
|
+
) -> dict[str, Any] | None:
|
|
66
|
+
"""Call the appropriate @auth.on.* handler for authorization.
|
|
67
|
+
|
|
68
|
+
This function resolves the most specific handler for the given resource
|
|
69
|
+
and action, calls it with the auth context and value, and interprets
|
|
70
|
+
the result.
|
|
71
|
+
|
|
72
|
+
**Default Behavior (Non-Interruptive):**
|
|
73
|
+
- If no auth is configured → allows by default (returns None)
|
|
74
|
+
- If no handlers are defined → allows by default (returns None)
|
|
75
|
+
- If handler returns None/True → allows (returns None)
|
|
76
|
+
- If handler returns dict → allows with filters applied (returns dict)
|
|
77
|
+
- **Only interrupts if handler returns False or raises exception**
|
|
78
|
+
|
|
79
|
+
This ensures that developers using raw Aegra without custom authorization
|
|
80
|
+
handlers will have a working system out-of-the-box. Handlers are purely
|
|
81
|
+
additive - they can inject metadata, apply filters, or deny access, but
|
|
82
|
+
they don't interrupt the flow unless explicitly configured to do so.
|
|
83
|
+
|
|
84
|
+
Handler resolution priority (most specific first):
|
|
85
|
+
1. Resource+action specific (e.g., "threads", "create")
|
|
86
|
+
2. Resource-specific (e.g., "threads", "*")
|
|
87
|
+
3. Action-specific (e.g., "*", "create")
|
|
88
|
+
4. Global handler ("*", "*")
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
ctx: Auth context wrapper with user, resource, and action
|
|
92
|
+
value: The data being authorized (request body, search filters, etc.)
|
|
93
|
+
This dict may be modified by the handler (e.g., injecting metadata)
|
|
94
|
+
|
|
95
|
+
Returns:
|
|
96
|
+
None: Request allowed, no filters to apply
|
|
97
|
+
dict: Filter dict to apply to queries (e.g., {"user_id": "123"})
|
|
98
|
+
|
|
99
|
+
Raises:
|
|
100
|
+
HTTPException(403): If handler returns False or raises AssertionError
|
|
101
|
+
HTTPException(500): If handler raises unexpected exception or returns invalid type
|
|
102
|
+
"""
|
|
103
|
+
if ctx is None:
|
|
104
|
+
# No auth context means no authorization check needed
|
|
105
|
+
# This allows the request to proceed normally
|
|
106
|
+
return None
|
|
107
|
+
|
|
108
|
+
auth = get_auth_instance()
|
|
109
|
+
if auth is None:
|
|
110
|
+
# No auth configured, allow by default
|
|
111
|
+
# This ensures raw Aegra works out-of-the-box without interruption
|
|
112
|
+
return None
|
|
113
|
+
|
|
114
|
+
# Convert to AuthContext
|
|
115
|
+
auth_ctx = ctx.to_langgraph_context()
|
|
116
|
+
|
|
117
|
+
# Find the most specific handler
|
|
118
|
+
handler = _get_handler(auth, auth_ctx.resource, auth_ctx.action)
|
|
119
|
+
if handler is None:
|
|
120
|
+
# No handler for this resource/action, allow by default
|
|
121
|
+
# Developers can use Aegra without defining handlers - it won't break
|
|
122
|
+
return None
|
|
123
|
+
|
|
124
|
+
try:
|
|
125
|
+
# Call the handler with context and value
|
|
126
|
+
result = await handler(ctx=auth_ctx, value=value)
|
|
127
|
+
except Auth.exceptions.HTTPException as e:
|
|
128
|
+
# Handler raised HTTP exception, convert to FastAPI HTTPException
|
|
129
|
+
raise HTTPException(
|
|
130
|
+
status_code=e.status_code,
|
|
131
|
+
detail=e.detail,
|
|
132
|
+
headers=dict(e.headers) if hasattr(e, "headers") and e.headers else None,
|
|
133
|
+
) from e
|
|
134
|
+
except AssertionError as e:
|
|
135
|
+
# Handler used assert for authorization check
|
|
136
|
+
raise HTTPException(status_code=403, detail=str(e)) from e
|
|
137
|
+
except Exception as e:
|
|
138
|
+
# Unexpected error in handler
|
|
139
|
+
raise HTTPException(status_code=500, detail=f"Authorization error: {str(e)}") from e
|
|
140
|
+
|
|
141
|
+
# Interpret handler result
|
|
142
|
+
if result in (None, True):
|
|
143
|
+
# Allow request, no filters
|
|
144
|
+
return None
|
|
145
|
+
|
|
146
|
+
if result is False:
|
|
147
|
+
# Deny request
|
|
148
|
+
raise HTTPException(status_code=403, detail="Forbidden")
|
|
149
|
+
|
|
150
|
+
if isinstance(result, dict):
|
|
151
|
+
# Return filter dict to apply to queries
|
|
152
|
+
return result
|
|
153
|
+
|
|
154
|
+
# Invalid return type
|
|
155
|
+
raise HTTPException(
|
|
156
|
+
status_code=500,
|
|
157
|
+
detail=f"Auth handler returned invalid type: {type(result)}. Expected dict, None, True, or False.",
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _get_handler(
|
|
162
|
+
auth: Auth,
|
|
163
|
+
resource: str,
|
|
164
|
+
action: str,
|
|
165
|
+
) -> Any | None:
|
|
166
|
+
"""Find the most specific handler for resource+action.
|
|
167
|
+
|
|
168
|
+
Handler resolution follows this priority order (most specific first):
|
|
169
|
+
1. (resource, action) - e.g., ("threads", "create")
|
|
170
|
+
2. (resource, "*") - e.g., ("threads", "*")
|
|
171
|
+
3. ("*", action) - e.g., ("*", "create")
|
|
172
|
+
4. ("*", "*") - global handler
|
|
173
|
+
|
|
174
|
+
Args:
|
|
175
|
+
auth: Auth instance with registered handlers
|
|
176
|
+
resource: Resource name (e.g., "threads", "assistants")
|
|
177
|
+
action: Action name (e.g., "create", "read", "update")
|
|
178
|
+
|
|
179
|
+
Returns:
|
|
180
|
+
Handler function or None if no handler found
|
|
181
|
+
"""
|
|
182
|
+
# Check cache first
|
|
183
|
+
key = (resource, action)
|
|
184
|
+
if key in auth._handler_cache:
|
|
185
|
+
return auth._handler_cache[key]
|
|
186
|
+
|
|
187
|
+
# Priority order (most specific first)
|
|
188
|
+
keys = [
|
|
189
|
+
(resource, action), # Most specific: exact resource+action
|
|
190
|
+
(resource, "*"), # Resource-specific: all actions on resource
|
|
191
|
+
("*", action), # Action-specific: all resources for action
|
|
192
|
+
("*", "*"), # Global: all resources and actions
|
|
193
|
+
]
|
|
194
|
+
|
|
195
|
+
# Find first matching handler
|
|
196
|
+
for check_key in keys:
|
|
197
|
+
if check_key in auth._handlers and auth._handlers[check_key]:
|
|
198
|
+
# Get the last registered handler (most recent wins)
|
|
199
|
+
handler = auth._handlers[check_key][-1]
|
|
200
|
+
# Cache the result
|
|
201
|
+
auth._handler_cache[key] = handler
|
|
202
|
+
return handler
|
|
203
|
+
|
|
204
|
+
# Check global handlers (fallback for backward compatibility)
|
|
205
|
+
if auth._global_handlers:
|
|
206
|
+
handler = auth._global_handlers[-1]
|
|
207
|
+
auth._handler_cache[key] = handler
|
|
208
|
+
return handler
|
|
209
|
+
|
|
210
|
+
return None
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def build_auth_context(
|
|
214
|
+
user: User,
|
|
215
|
+
resource: str,
|
|
216
|
+
action: str,
|
|
217
|
+
) -> AuthContextWrapper:
|
|
218
|
+
"""Build AuthContextWrapper from user and operation info.
|
|
219
|
+
|
|
220
|
+
This is a convenience function to create an AuthContextWrapper with
|
|
221
|
+
the correct resource and action for a given operation.
|
|
222
|
+
|
|
223
|
+
Args:
|
|
224
|
+
user: Authenticated user from require_auth dependency
|
|
225
|
+
resource: Resource being accessed (e.g., "threads", "assistants")
|
|
226
|
+
action: Action being performed (e.g., "create", "read", "update")
|
|
227
|
+
|
|
228
|
+
Returns:
|
|
229
|
+
AuthContextWrapper instance ready to pass to handle_event()
|
|
230
|
+
|
|
231
|
+
Example:
|
|
232
|
+
```python
|
|
233
|
+
@router.post("/threads")
|
|
234
|
+
async def create_thread(
|
|
235
|
+
request: ThreadCreate,
|
|
236
|
+
user: User = Depends(require_auth),
|
|
237
|
+
):
|
|
238
|
+
ctx = build_auth_context(user, "threads", "create")
|
|
239
|
+
value = request.model_dump()
|
|
240
|
+
filters = await handle_event(ctx, value)
|
|
241
|
+
# Use filters or modified value...
|
|
242
|
+
```
|
|
243
|
+
"""
|
|
244
|
+
return AuthContextWrapper(
|
|
245
|
+
user=user,
|
|
246
|
+
resource=resource,
|
|
247
|
+
action=action,
|
|
248
|
+
)
|
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Authentication middleware integration for Aegra.
|
|
3
|
+
|
|
4
|
+
This module integrates authentication system with FastAPI
|
|
5
|
+
using Starlette's AuthenticationMiddleware.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import functools
|
|
9
|
+
import importlib
|
|
10
|
+
import importlib.util
|
|
11
|
+
import sys
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
import structlog
|
|
16
|
+
from langgraph_sdk import Auth
|
|
17
|
+
from langgraph_sdk.auth.types import MinimalUserDict
|
|
18
|
+
from starlette.authentication import (
|
|
19
|
+
AuthCredentials,
|
|
20
|
+
AuthenticationBackend,
|
|
21
|
+
AuthenticationError,
|
|
22
|
+
BaseUser,
|
|
23
|
+
)
|
|
24
|
+
from starlette.requests import HTTPConnection
|
|
25
|
+
from starlette.responses import JSONResponse
|
|
26
|
+
|
|
27
|
+
from aegra_api.config import get_config_dir, load_auth_config
|
|
28
|
+
from aegra_api.models.errors import AgentProtocolError
|
|
29
|
+
from aegra_api.settings import settings
|
|
30
|
+
|
|
31
|
+
logger = structlog.getLogger(__name__)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class LangGraphUser(BaseUser):
|
|
35
|
+
"""
|
|
36
|
+
User wrapper that implements Starlette's BaseUser interface
|
|
37
|
+
while preserving auth data.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
def __init__(self, user_data: Auth.types.MinimalUserDict):
|
|
41
|
+
self._user_data = user_data
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def identity(self) -> str:
|
|
45
|
+
return self._user_data["identity"]
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def is_authenticated(self) -> bool:
|
|
49
|
+
return self._user_data.get("is_authenticated", True)
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def display_name(self) -> str:
|
|
53
|
+
return self._user_data.get("display_name", self.identity)
|
|
54
|
+
|
|
55
|
+
def __getattr__(self, name: str) -> Any:
|
|
56
|
+
"""Allow access to any additional fields from auth data"""
|
|
57
|
+
if name in self._user_data:
|
|
58
|
+
return self._user_data[name]
|
|
59
|
+
raise AttributeError(f"'{self.__class__.__name__}' object has no attribute '{name}'")
|
|
60
|
+
|
|
61
|
+
def to_dict(self) -> MinimalUserDict:
|
|
62
|
+
"""Return the underlying user data dict"""
|
|
63
|
+
return self._user_data.copy()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class LangGraphAuthBackend(AuthenticationBackend):
|
|
67
|
+
"""
|
|
68
|
+
Authentication backend that uses the auth system.
|
|
69
|
+
|
|
70
|
+
This bridges @auth.authenticate handlers with
|
|
71
|
+
Starlette's AuthenticationMiddleware.
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
def __init__(self) -> None:
|
|
75
|
+
self.auth_instance = self._load_auth_instance()
|
|
76
|
+
|
|
77
|
+
def _load_auth_instance(self) -> Auth | None:
|
|
78
|
+
"""Load the auth instance from config or fallback to hardcoded candidates.
|
|
79
|
+
|
|
80
|
+
Resolution order:
|
|
81
|
+
1. Load from aegra.json auth.path config
|
|
82
|
+
2. If no auth file found, returns None (noop handled directly in authenticate())
|
|
83
|
+
|
|
84
|
+
Returns:
|
|
85
|
+
Auth instance or None if not found (noop handled in authenticate() method)
|
|
86
|
+
"""
|
|
87
|
+
# 1. Try loading from config
|
|
88
|
+
try:
|
|
89
|
+
auth_config = load_auth_config()
|
|
90
|
+
if auth_config and "path" in auth_config:
|
|
91
|
+
auth_path = auth_config["path"]
|
|
92
|
+
logger.info(f"Loading auth from config path: {auth_path}")
|
|
93
|
+
auth_instance = self._load_from_path(auth_path)
|
|
94
|
+
if auth_instance:
|
|
95
|
+
return auth_instance
|
|
96
|
+
logger.warning(f"Failed to load auth from config path: {auth_path}")
|
|
97
|
+
except Exception as e:
|
|
98
|
+
logger.warning(f"Error loading auth config: {e}")
|
|
99
|
+
|
|
100
|
+
logger.debug("No auth instance found from config")
|
|
101
|
+
return None
|
|
102
|
+
|
|
103
|
+
def _load_from_path(self, path: str) -> Auth | None:
|
|
104
|
+
"""Load auth instance from path in format './file.py:var' or 'module:var'.
|
|
105
|
+
|
|
106
|
+
Relative paths are resolved from the config file directory.
|
|
107
|
+
|
|
108
|
+
Args:
|
|
109
|
+
path: Import path in format './file.py:variable' or 'module.path:variable'
|
|
110
|
+
|
|
111
|
+
Returns:
|
|
112
|
+
Auth instance or None if loading fails
|
|
113
|
+
"""
|
|
114
|
+
if ":" not in path:
|
|
115
|
+
logger.error(f"Invalid auth path format (missing ':'): {path}")
|
|
116
|
+
return None
|
|
117
|
+
|
|
118
|
+
module_path, var_name = path.rsplit(":", 1)
|
|
119
|
+
|
|
120
|
+
# Handle file path format: ./file.py or ./path/to/file.py or ../file.py
|
|
121
|
+
is_file_path = module_path.endswith(".py") or module_path.startswith("./") or module_path.startswith("../")
|
|
122
|
+
if is_file_path:
|
|
123
|
+
file_path = Path(module_path)
|
|
124
|
+
|
|
125
|
+
# Resolve relative paths from config directory
|
|
126
|
+
if not file_path.is_absolute():
|
|
127
|
+
config_dir = get_config_dir()
|
|
128
|
+
if config_dir:
|
|
129
|
+
file_path = (config_dir / file_path).resolve()
|
|
130
|
+
else:
|
|
131
|
+
# Fallback to CWD if no config found
|
|
132
|
+
file_path = (Path.cwd() / file_path).resolve()
|
|
133
|
+
|
|
134
|
+
return self._load_from_file(file_path, var_name)
|
|
135
|
+
|
|
136
|
+
# Handle module format: module.path
|
|
137
|
+
return self._load_from_module(module_path, var_name)
|
|
138
|
+
|
|
139
|
+
def _load_from_file(self, file_path: Path, var_name: str) -> Auth | None:
|
|
140
|
+
"""Load auth instance from a file path.
|
|
141
|
+
|
|
142
|
+
Args:
|
|
143
|
+
file_path: Path to the Python file
|
|
144
|
+
var_name: Name of the variable to load
|
|
145
|
+
|
|
146
|
+
Returns:
|
|
147
|
+
Auth instance or None if loading fails
|
|
148
|
+
"""
|
|
149
|
+
try:
|
|
150
|
+
if not file_path.exists():
|
|
151
|
+
logger.warning(f"Auth file not found: {file_path}")
|
|
152
|
+
return None
|
|
153
|
+
|
|
154
|
+
if not file_path.is_file():
|
|
155
|
+
logger.warning(f"Auth path is not a file: {file_path} (is directory: {file_path.is_dir()})")
|
|
156
|
+
return None
|
|
157
|
+
|
|
158
|
+
# Create a unique module name based on the file path
|
|
159
|
+
module_name = f"auth_module_{file_path.stem}"
|
|
160
|
+
|
|
161
|
+
spec = importlib.util.spec_from_file_location(module_name, str(file_path))
|
|
162
|
+
if spec is None or spec.loader is None:
|
|
163
|
+
logger.error(f"Could not load auth module from {file_path}")
|
|
164
|
+
return None
|
|
165
|
+
|
|
166
|
+
auth_module = importlib.util.module_from_spec(spec)
|
|
167
|
+
sys.modules[module_name] = auth_module
|
|
168
|
+
spec.loader.exec_module(auth_module)
|
|
169
|
+
|
|
170
|
+
auth_instance = getattr(auth_module, var_name, None)
|
|
171
|
+
if not isinstance(auth_instance, Auth):
|
|
172
|
+
logger.error(f"Variable '{var_name}' in {file_path} is not an Auth instance")
|
|
173
|
+
return None
|
|
174
|
+
|
|
175
|
+
logger.info(f"Successfully loaded auth instance from {file_path}:{var_name}")
|
|
176
|
+
return auth_instance
|
|
177
|
+
|
|
178
|
+
except Exception as e:
|
|
179
|
+
logger.error(f"Error loading auth from {file_path}: {e}", exc_info=True)
|
|
180
|
+
return None
|
|
181
|
+
|
|
182
|
+
def _load_from_module(self, module_path: str, var_name: str) -> Auth | None:
|
|
183
|
+
"""Load auth instance from an installed module.
|
|
184
|
+
|
|
185
|
+
Args:
|
|
186
|
+
module_path: Dotted module path (e.g., 'mypackage.auth')
|
|
187
|
+
var_name: Name of the variable to load
|
|
188
|
+
|
|
189
|
+
Returns:
|
|
190
|
+
Auth instance or None if loading fails
|
|
191
|
+
"""
|
|
192
|
+
try:
|
|
193
|
+
module = importlib.import_module(module_path)
|
|
194
|
+
auth_instance = getattr(module, var_name, None)
|
|
195
|
+
|
|
196
|
+
if not isinstance(auth_instance, Auth):
|
|
197
|
+
logger.error(f"Variable '{var_name}' in module {module_path} is not an Auth instance")
|
|
198
|
+
return None
|
|
199
|
+
|
|
200
|
+
logger.info(f"Successfully loaded auth instance from {module_path}:{var_name}")
|
|
201
|
+
return auth_instance
|
|
202
|
+
|
|
203
|
+
except ImportError as e:
|
|
204
|
+
logger.error(f"Could not import module {module_path}: {e}")
|
|
205
|
+
return None
|
|
206
|
+
except Exception as e:
|
|
207
|
+
logger.error(f"Error loading auth from {module_path}: {e}", exc_info=True)
|
|
208
|
+
return None
|
|
209
|
+
|
|
210
|
+
async def authenticate(self, conn: HTTPConnection) -> tuple[AuthCredentials, BaseUser] | None:
|
|
211
|
+
"""
|
|
212
|
+
Authenticate request using the configured auth system.
|
|
213
|
+
|
|
214
|
+
Args:
|
|
215
|
+
conn: HTTP connection containing request headers
|
|
216
|
+
|
|
217
|
+
Returns:
|
|
218
|
+
Tuple of (credentials, user) if authenticated, None otherwise
|
|
219
|
+
|
|
220
|
+
Raises:
|
|
221
|
+
AuthenticationError: If authentication fails
|
|
222
|
+
"""
|
|
223
|
+
# Handle noop auth when no auth instance is configured
|
|
224
|
+
# Default to noop (anonymous) authentication when no auth file is found,
|
|
225
|
+
# regardless of AUTH_TYPE setting. This ensures the server works out-of-the-box.
|
|
226
|
+
if self.auth_instance is None:
|
|
227
|
+
logger.debug("No auth file configured, defaulting to noop (anonymous) authentication")
|
|
228
|
+
# Return anonymous user when no auth is configured
|
|
229
|
+
user_data: Auth.types.MinimalUserDict = {
|
|
230
|
+
"identity": "anonymous",
|
|
231
|
+
"display_name": "Anonymous User",
|
|
232
|
+
"is_authenticated": True,
|
|
233
|
+
}
|
|
234
|
+
credentials = AuthCredentials([])
|
|
235
|
+
user = LangGraphUser(user_data)
|
|
236
|
+
return credentials, user
|
|
237
|
+
|
|
238
|
+
if self.auth_instance._authenticate_handler is None:
|
|
239
|
+
logger.warning("No authenticate handler configured, skipping authentication")
|
|
240
|
+
return None
|
|
241
|
+
|
|
242
|
+
try:
|
|
243
|
+
# Convert headers to dict format expected by auth handlers
|
|
244
|
+
headers = {
|
|
245
|
+
key.decode() if isinstance(key, bytes) else key: value.decode() if isinstance(value, bytes) else value
|
|
246
|
+
for key, value in conn.headers.items()
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
# Call the authenticate handler
|
|
250
|
+
user_data = await self.auth_instance._authenticate_handler(headers)
|
|
251
|
+
|
|
252
|
+
if not user_data or not isinstance(user_data, dict):
|
|
253
|
+
raise AuthenticationError("Invalid user data returned from auth handler")
|
|
254
|
+
|
|
255
|
+
if "identity" not in user_data:
|
|
256
|
+
raise AuthenticationError("Auth handler must return 'identity' field")
|
|
257
|
+
|
|
258
|
+
# Extract permissions for credentials
|
|
259
|
+
permissions = user_data.get("permissions", [])
|
|
260
|
+
if isinstance(permissions, str):
|
|
261
|
+
permissions = [permissions]
|
|
262
|
+
|
|
263
|
+
# Create Starlette-compatible user and credentials
|
|
264
|
+
credentials = AuthCredentials(permissions)
|
|
265
|
+
user = LangGraphUser(user_data)
|
|
266
|
+
|
|
267
|
+
logger.debug(f"Successfully authenticated user: {user.identity}")
|
|
268
|
+
return credentials, user
|
|
269
|
+
|
|
270
|
+
except Auth.exceptions.HTTPException as e:
|
|
271
|
+
logger.warning(f"Authentication failed: {e.detail}")
|
|
272
|
+
raise AuthenticationError(e.detail) from e
|
|
273
|
+
|
|
274
|
+
except Exception as e:
|
|
275
|
+
logger.error(f"Unexpected error during authentication: {e}", exc_info=True)
|
|
276
|
+
raise AuthenticationError("Authentication system error") from e
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def get_auth_backend() -> AuthenticationBackend:
|
|
280
|
+
"""
|
|
281
|
+
Get authentication backend based on AUTH_TYPE environment variable.
|
|
282
|
+
|
|
283
|
+
Returns:
|
|
284
|
+
AuthenticationBackend instance
|
|
285
|
+
"""
|
|
286
|
+
auth_type = settings.app.AUTH_TYPE
|
|
287
|
+
|
|
288
|
+
if auth_type in ["noop", "custom"]:
|
|
289
|
+
logger.debug(f"Using auth backend with type: {auth_type}")
|
|
290
|
+
return LangGraphAuthBackend()
|
|
291
|
+
else:
|
|
292
|
+
logger.warning(f"Unknown AUTH_TYPE: {auth_type}, using noop")
|
|
293
|
+
return LangGraphAuthBackend()
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def on_auth_error(conn: HTTPConnection, exc: AuthenticationError) -> JSONResponse:
|
|
297
|
+
"""
|
|
298
|
+
Handle authentication errors in Agent Protocol format.
|
|
299
|
+
|
|
300
|
+
Args:
|
|
301
|
+
conn: HTTP connection
|
|
302
|
+
exc: Authentication error
|
|
303
|
+
|
|
304
|
+
Returns:
|
|
305
|
+
JSON response with Agent Protocol error format
|
|
306
|
+
"""
|
|
307
|
+
logger.warning(f"Authentication error for {conn.url}: {exc}")
|
|
308
|
+
|
|
309
|
+
return JSONResponse(
|
|
310
|
+
status_code=401,
|
|
311
|
+
content=AgentProtocolError(
|
|
312
|
+
error="unauthorized",
|
|
313
|
+
message=str(exc),
|
|
314
|
+
details={"authentication_required": True},
|
|
315
|
+
).model_dump(),
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
@functools.lru_cache(maxsize=1)
|
|
320
|
+
def get_auth_instance() -> Auth | None:
|
|
321
|
+
"""Get cached Auth instance for use by other modules.
|
|
322
|
+
|
|
323
|
+
Uses LRU cache to ensure only one Auth instance is loaded per process.
|
|
324
|
+
This allows other modules to access the same Auth instance used by
|
|
325
|
+
the middleware without re-loading it.
|
|
326
|
+
|
|
327
|
+
Returns:
|
|
328
|
+
Auth instance or None if not configured/found
|
|
329
|
+
"""
|
|
330
|
+
backend = LangGraphAuthBackend()
|
|
331
|
+
return backend.auth_instance
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""Database manager with LangGraph integration"""
|
|
2
|
+
|
|
3
|
+
import structlog
|
|
4
|
+
from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver
|
|
5
|
+
from langgraph.store.postgres.aio import AsyncPostgresStore
|
|
6
|
+
from psycopg.rows import dict_row
|
|
7
|
+
from psycopg_pool import AsyncConnectionPool
|
|
8
|
+
from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine
|
|
9
|
+
|
|
10
|
+
from aegra_api.config import load_store_config
|
|
11
|
+
from aegra_api.settings import settings
|
|
12
|
+
|
|
13
|
+
logger = structlog.get_logger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class DatabaseManager:
|
|
17
|
+
"""Manages database connections and LangGraph persistence components"""
|
|
18
|
+
|
|
19
|
+
def __init__(self) -> None:
|
|
20
|
+
self.engine: AsyncEngine | None = None
|
|
21
|
+
|
|
22
|
+
# Shared pool for LangGraph components (Checkpointer + Store)
|
|
23
|
+
self.lg_pool: AsyncConnectionPool | None = None
|
|
24
|
+
self._checkpointer: AsyncPostgresSaver | None = None
|
|
25
|
+
self._store: AsyncPostgresStore | None = None
|
|
26
|
+
self._database_url = settings.db.database_url
|
|
27
|
+
|
|
28
|
+
async def initialize(self) -> None:
|
|
29
|
+
"""Initialize database connections and LangGraph components"""
|
|
30
|
+
# Idempotency check: if already initialized, do nothing
|
|
31
|
+
if self.engine:
|
|
32
|
+
return
|
|
33
|
+
|
|
34
|
+
# 1. SQLAlchemy Engine (app metadata, uses asyncpg)
|
|
35
|
+
# We strictly limit this pool because the main load
|
|
36
|
+
# is handled by LangGraph components.
|
|
37
|
+
self.engine = create_async_engine(
|
|
38
|
+
self._database_url,
|
|
39
|
+
pool_size=settings.pool.SQLALCHEMY_POOL_SIZE,
|
|
40
|
+
max_overflow=settings.pool.SQLALCHEMY_MAX_OVERFLOW,
|
|
41
|
+
pool_pre_ping=True,
|
|
42
|
+
echo=settings.db.DB_ECHO_LOG,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
lg_max = settings.pool.LANGGRAPH_MAX_POOL_SIZE
|
|
46
|
+
lg_kwargs = {
|
|
47
|
+
"autocommit": True,
|
|
48
|
+
"prepare_threshold": 0, # Optimization for PgBouncer/Kubernetes compatibility
|
|
49
|
+
"row_factory": dict_row, # LangGraph requires dictionary rows, not tuples
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
# Create a single shared pool.
|
|
53
|
+
# 'open=False' is important to avoid RuntimeWarning; we open it explicitly below.
|
|
54
|
+
self.lg_pool = AsyncConnectionPool(
|
|
55
|
+
conninfo=settings.db.database_url_sync,
|
|
56
|
+
min_size=settings.pool.LANGGRAPH_MIN_POOL_SIZE,
|
|
57
|
+
max_size=lg_max,
|
|
58
|
+
open=False,
|
|
59
|
+
kwargs=lg_kwargs,
|
|
60
|
+
check=AsyncConnectionPool.check_connection,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
# Explicitly open the pool
|
|
64
|
+
await self.lg_pool.open()
|
|
65
|
+
|
|
66
|
+
# 2. Initialize LangGraph components using the shared pool
|
|
67
|
+
# Passing 'conn=self.lg_pool' prevents components from creating their own pools.
|
|
68
|
+
|
|
69
|
+
logger.info(f"Initializing LangGraph components with shared pool (max {lg_max} conns)...")
|
|
70
|
+
|
|
71
|
+
self._checkpointer = AsyncPostgresSaver(conn=self.lg_pool)
|
|
72
|
+
await self._checkpointer.setup() # Ensure tables exist
|
|
73
|
+
|
|
74
|
+
# Load store configuration for semantic search (if configured)
|
|
75
|
+
store_config = load_store_config()
|
|
76
|
+
index_config = store_config.get("index") if store_config else None
|
|
77
|
+
|
|
78
|
+
self._store = AsyncPostgresStore(conn=self.lg_pool, index=index_config)
|
|
79
|
+
await self._store.setup() # Ensure tables exist
|
|
80
|
+
|
|
81
|
+
if index_config:
|
|
82
|
+
embed_model = index_config.get("embed", "unknown")
|
|
83
|
+
logger.info(f"Semantic store enabled with embeddings: {embed_model}")
|
|
84
|
+
|
|
85
|
+
logger.info("✅ Database and LangGraph components initialized")
|
|
86
|
+
|
|
87
|
+
async def close(self) -> None:
|
|
88
|
+
"""Close database connections"""
|
|
89
|
+
# Close SQLAlchemy engine
|
|
90
|
+
if self.engine:
|
|
91
|
+
await self.engine.dispose()
|
|
92
|
+
self.engine = None
|
|
93
|
+
|
|
94
|
+
# Close shared LangGraph pool
|
|
95
|
+
if self.lg_pool:
|
|
96
|
+
await self.lg_pool.close()
|
|
97
|
+
self.lg_pool = None
|
|
98
|
+
self._checkpointer = None
|
|
99
|
+
self._store = None
|
|
100
|
+
|
|
101
|
+
logger.info("✅ Database connections closed")
|
|
102
|
+
|
|
103
|
+
def get_checkpointer(self) -> AsyncPostgresSaver:
|
|
104
|
+
"""Return the live AsyncPostgresSaver instance."""
|
|
105
|
+
if self._checkpointer is None:
|
|
106
|
+
raise RuntimeError("Database not initialized")
|
|
107
|
+
return self._checkpointer
|
|
108
|
+
|
|
109
|
+
def get_store(self) -> AsyncPostgresStore:
|
|
110
|
+
"""Return the live AsyncPostgresStore instance."""
|
|
111
|
+
if self._store is None:
|
|
112
|
+
raise RuntimeError("Database not initialized")
|
|
113
|
+
return self._store
|
|
114
|
+
|
|
115
|
+
def get_engine(self) -> AsyncEngine:
|
|
116
|
+
"""Get the SQLAlchemy engine for metadata tables"""
|
|
117
|
+
if not self.engine:
|
|
118
|
+
raise RuntimeError("Database not initialized")
|
|
119
|
+
return self.engine
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
# Global database manager instance
|
|
123
|
+
db_manager = DatabaseManager()
|