langgraph-api 0.4.1__py3-none-any.whl → 0.7.3__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.
- langgraph_api/__init__.py +1 -1
- langgraph_api/api/__init__.py +111 -51
- langgraph_api/api/a2a.py +1610 -0
- langgraph_api/api/assistants.py +212 -89
- langgraph_api/api/mcp.py +3 -3
- langgraph_api/api/meta.py +52 -28
- langgraph_api/api/openapi.py +27 -17
- langgraph_api/api/profile.py +108 -0
- langgraph_api/api/runs.py +342 -195
- langgraph_api/api/store.py +19 -2
- langgraph_api/api/threads.py +209 -27
- langgraph_api/asgi_transport.py +14 -9
- langgraph_api/asyncio.py +14 -4
- langgraph_api/auth/custom.py +52 -37
- langgraph_api/auth/langsmith/backend.py +4 -3
- langgraph_api/auth/langsmith/client.py +13 -8
- langgraph_api/cli.py +230 -133
- langgraph_api/command.py +5 -3
- langgraph_api/config/__init__.py +532 -0
- langgraph_api/config/_parse.py +58 -0
- langgraph_api/config/schemas.py +431 -0
- langgraph_api/cron_scheduler.py +17 -1
- langgraph_api/encryption/__init__.py +15 -0
- langgraph_api/encryption/aes_json.py +158 -0
- langgraph_api/encryption/context.py +35 -0
- langgraph_api/encryption/custom.py +280 -0
- langgraph_api/encryption/middleware.py +632 -0
- langgraph_api/encryption/shared.py +63 -0
- langgraph_api/errors.py +12 -1
- langgraph_api/executor_entrypoint.py +11 -6
- langgraph_api/feature_flags.py +29 -0
- langgraph_api/graph.py +176 -76
- langgraph_api/grpc/client.py +313 -0
- langgraph_api/grpc/config_conversion.py +231 -0
- langgraph_api/grpc/generated/__init__.py +29 -0
- langgraph_api/grpc/generated/checkpointer_pb2.py +63 -0
- langgraph_api/grpc/generated/checkpointer_pb2.pyi +99 -0
- langgraph_api/grpc/generated/checkpointer_pb2_grpc.py +329 -0
- langgraph_api/grpc/generated/core_api_pb2.py +216 -0
- langgraph_api/grpc/generated/core_api_pb2.pyi +905 -0
- langgraph_api/grpc/generated/core_api_pb2_grpc.py +1621 -0
- langgraph_api/grpc/generated/engine_common_pb2.py +219 -0
- langgraph_api/grpc/generated/engine_common_pb2.pyi +722 -0
- langgraph_api/grpc/generated/engine_common_pb2_grpc.py +24 -0
- langgraph_api/grpc/generated/enum_cancel_run_action_pb2.py +37 -0
- langgraph_api/grpc/generated/enum_cancel_run_action_pb2.pyi +12 -0
- langgraph_api/grpc/generated/enum_cancel_run_action_pb2_grpc.py +24 -0
- langgraph_api/grpc/generated/enum_control_signal_pb2.py +37 -0
- langgraph_api/grpc/generated/enum_control_signal_pb2.pyi +16 -0
- langgraph_api/grpc/generated/enum_control_signal_pb2_grpc.py +24 -0
- langgraph_api/grpc/generated/enum_durability_pb2.py +37 -0
- langgraph_api/grpc/generated/enum_durability_pb2.pyi +16 -0
- langgraph_api/grpc/generated/enum_durability_pb2_grpc.py +24 -0
- langgraph_api/grpc/generated/enum_multitask_strategy_pb2.py +37 -0
- langgraph_api/grpc/generated/enum_multitask_strategy_pb2.pyi +16 -0
- langgraph_api/grpc/generated/enum_multitask_strategy_pb2_grpc.py +24 -0
- langgraph_api/grpc/generated/enum_run_status_pb2.py +37 -0
- langgraph_api/grpc/generated/enum_run_status_pb2.pyi +22 -0
- langgraph_api/grpc/generated/enum_run_status_pb2_grpc.py +24 -0
- langgraph_api/grpc/generated/enum_stream_mode_pb2.py +37 -0
- langgraph_api/grpc/generated/enum_stream_mode_pb2.pyi +28 -0
- langgraph_api/grpc/generated/enum_stream_mode_pb2_grpc.py +24 -0
- langgraph_api/grpc/generated/enum_thread_status_pb2.py +37 -0
- langgraph_api/grpc/generated/enum_thread_status_pb2.pyi +16 -0
- langgraph_api/grpc/generated/enum_thread_status_pb2_grpc.py +24 -0
- langgraph_api/grpc/generated/enum_thread_stream_mode_pb2.py +37 -0
- langgraph_api/grpc/generated/enum_thread_stream_mode_pb2.pyi +16 -0
- langgraph_api/grpc/generated/enum_thread_stream_mode_pb2_grpc.py +24 -0
- langgraph_api/grpc/generated/errors_pb2.py +39 -0
- langgraph_api/grpc/generated/errors_pb2.pyi +21 -0
- langgraph_api/grpc/generated/errors_pb2_grpc.py +24 -0
- langgraph_api/grpc/ops/__init__.py +370 -0
- langgraph_api/grpc/ops/assistants.py +424 -0
- langgraph_api/grpc/ops/runs.py +792 -0
- langgraph_api/grpc/ops/threads.py +1013 -0
- langgraph_api/http.py +16 -5
- langgraph_api/http_metrics.py +15 -35
- langgraph_api/http_metrics_utils.py +38 -0
- langgraph_api/js/build.mts +1 -1
- langgraph_api/js/client.http.mts +13 -7
- langgraph_api/js/client.mts +2 -5
- langgraph_api/js/package.json +29 -28
- langgraph_api/js/remote.py +56 -30
- langgraph_api/js/src/graph.mts +20 -0
- langgraph_api/js/sse.py +2 -2
- langgraph_api/js/ui.py +1 -1
- langgraph_api/js/yarn.lock +1204 -1006
- langgraph_api/logging.py +29 -2
- langgraph_api/metadata.py +99 -28
- langgraph_api/middleware/http_logger.py +7 -2
- langgraph_api/middleware/private_network.py +7 -7
- langgraph_api/models/run.py +54 -93
- langgraph_api/otel_context.py +205 -0
- langgraph_api/patch.py +5 -3
- langgraph_api/queue_entrypoint.py +154 -65
- langgraph_api/route.py +47 -5
- langgraph_api/schema.py +88 -10
- langgraph_api/self_hosted_logs.py +124 -0
- langgraph_api/self_hosted_metrics.py +450 -0
- langgraph_api/serde.py +79 -37
- langgraph_api/server.py +138 -60
- langgraph_api/state.py +4 -3
- langgraph_api/store.py +25 -16
- langgraph_api/stream.py +80 -29
- langgraph_api/thread_ttl.py +31 -13
- langgraph_api/timing/__init__.py +25 -0
- langgraph_api/timing/profiler.py +200 -0
- langgraph_api/timing/timer.py +318 -0
- langgraph_api/utils/__init__.py +53 -8
- langgraph_api/utils/cache.py +47 -10
- langgraph_api/utils/config.py +2 -1
- langgraph_api/utils/errors.py +77 -0
- langgraph_api/utils/future.py +10 -6
- langgraph_api/utils/headers.py +76 -2
- langgraph_api/utils/retriable_client.py +74 -0
- langgraph_api/utils/stream_codec.py +315 -0
- langgraph_api/utils/uuids.py +29 -62
- langgraph_api/validation.py +9 -0
- langgraph_api/webhook.py +120 -6
- langgraph_api/worker.py +55 -24
- {langgraph_api-0.4.1.dist-info → langgraph_api-0.7.3.dist-info}/METADATA +16 -8
- langgraph_api-0.7.3.dist-info/RECORD +168 -0
- {langgraph_api-0.4.1.dist-info → langgraph_api-0.7.3.dist-info}/WHEEL +1 -1
- langgraph_runtime/__init__.py +1 -0
- langgraph_runtime/routes.py +11 -0
- logging.json +1 -3
- openapi.json +839 -478
- langgraph_api/config.py +0 -387
- langgraph_api/js/isolate-0x130008000-46649-46649-v8.log +0 -4430
- langgraph_api/js/isolate-0x138008000-44681-44681-v8.log +0 -4430
- langgraph_api/js/package-lock.json +0 -3308
- langgraph_api-0.4.1.dist-info/RECORD +0 -107
- /langgraph_api/{utils.py → grpc/__init__.py} +0 -0
- {langgraph_api-0.4.1.dist-info → langgraph_api-0.7.3.dist-info}/entry_points.txt +0 -0
- {langgraph_api-0.4.1.dist-info → langgraph_api-0.7.3.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,632 @@
|
|
|
1
|
+
"""Encryption/decryption middleware for API layer.
|
|
2
|
+
|
|
3
|
+
This module provides helpers to encrypt data before storing and decrypt
|
|
4
|
+
after retrieving, keeping encryption logic at the API layer.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import base64
|
|
11
|
+
from typing import TYPE_CHECKING, Any, cast
|
|
12
|
+
|
|
13
|
+
import orjson
|
|
14
|
+
import structlog
|
|
15
|
+
from starlette.authentication import BaseUser
|
|
16
|
+
from starlette.exceptions import HTTPException
|
|
17
|
+
from starlette.middleware.base import BaseHTTPMiddleware
|
|
18
|
+
from starlette.requests import Request # noqa: TC002
|
|
19
|
+
|
|
20
|
+
from langgraph_api.auth.noop import UnauthenticatedUser
|
|
21
|
+
from langgraph_api.config import LANGGRAPH_ENCRYPTION
|
|
22
|
+
from langgraph_api.encryption.aes_json import (
|
|
23
|
+
AesEncryptionInstance,
|
|
24
|
+
DecryptorMissingError,
|
|
25
|
+
is_aes_encryption_context,
|
|
26
|
+
)
|
|
27
|
+
from langgraph_api.encryption.context import (
|
|
28
|
+
get_encryption_context,
|
|
29
|
+
set_encryption_context,
|
|
30
|
+
)
|
|
31
|
+
from langgraph_api.encryption.custom import (
|
|
32
|
+
JsonEncryptionWrapper,
|
|
33
|
+
ModelType,
|
|
34
|
+
get_custom_encryption_instance,
|
|
35
|
+
)
|
|
36
|
+
from langgraph_api.encryption.shared import (
|
|
37
|
+
BLOB_ENCRYPTION_CONTEXT_KEY,
|
|
38
|
+
ENCRYPTION_CONTEXT_KEY,
|
|
39
|
+
get_encryption,
|
|
40
|
+
strip_encryption_metadata,
|
|
41
|
+
)
|
|
42
|
+
from langgraph_api.schema import NESTED_ENCRYPTED_SUBFIELDS
|
|
43
|
+
from langgraph_api.serde import Fragment, json_loads
|
|
44
|
+
|
|
45
|
+
if TYPE_CHECKING:
|
|
46
|
+
from collections.abc import Mapping, Sequence
|
|
47
|
+
|
|
48
|
+
# Only import EncryptionContext at module load if encryption is configured
|
|
49
|
+
# This avoids requiring langgraph-sdk>=0.2.14 for users who don't use encryption
|
|
50
|
+
if LANGGRAPH_ENCRYPTION:
|
|
51
|
+
from langgraph_sdk import EncryptionContext
|
|
52
|
+
|
|
53
|
+
logger = structlog.stdlib.get_logger(__name__)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _serialize_user_for_encryption(user: BaseUser) -> dict[str, Any]:
|
|
57
|
+
"""Serialize a BaseUser to a JSON-serializable dict for encryption.
|
|
58
|
+
|
|
59
|
+
Called by _prepare_data_for_encryption when langgraph_auth_user contains a
|
|
60
|
+
BaseUser that needs to be serialized before JSON encryption.
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
user: The BaseUser to serialize (ProxyUser, SimpleUser, or custom subclass)
|
|
64
|
+
|
|
65
|
+
Returns:
|
|
66
|
+
A JSON-serializable dict with user data
|
|
67
|
+
"""
|
|
68
|
+
# If the auth function returns a pydantic object as the User object, we
|
|
69
|
+
# want to preserve the additional fields
|
|
70
|
+
if hasattr(user, "model_dump") and callable(user.model_dump):
|
|
71
|
+
return cast("dict[str, Any]", user.model_dump())
|
|
72
|
+
|
|
73
|
+
# Plain BaseUser subclasses - extract the required properties
|
|
74
|
+
return {
|
|
75
|
+
"identity": user.identity,
|
|
76
|
+
"is_authenticated": user.is_authenticated,
|
|
77
|
+
"display_name": user.display_name,
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _prepare_data_for_encryption(data: dict[str, Any]) -> dict[str, Any]:
|
|
82
|
+
"""Prepare data dict for encryption by serializing non-JSON-serializable objects.
|
|
83
|
+
|
|
84
|
+
Specifically handles langgraph_auth_user which may contain BaseUser objects
|
|
85
|
+
that can't be JSON-serialized. Dicts pass through unchanged (already serializable).
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
data: The data dict to prepare
|
|
89
|
+
|
|
90
|
+
Returns:
|
|
91
|
+
A new dict with serialized values where needed
|
|
92
|
+
"""
|
|
93
|
+
if "langgraph_auth_user" not in data:
|
|
94
|
+
return data
|
|
95
|
+
|
|
96
|
+
user = data["langgraph_auth_user"]
|
|
97
|
+
if isinstance(user, BaseUser):
|
|
98
|
+
data = dict(data) # shallow copy
|
|
99
|
+
if isinstance(user, UnauthenticatedUser):
|
|
100
|
+
data["langgraph_auth_user"] = None
|
|
101
|
+
else:
|
|
102
|
+
data["langgraph_auth_user"] = _serialize_user_for_encryption(user)
|
|
103
|
+
|
|
104
|
+
return data
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def extract_encryption_context(request: Request) -> dict[str, Any]:
|
|
108
|
+
"""Extract encryption context from X-Encryption-Context header.
|
|
109
|
+
|
|
110
|
+
Args:
|
|
111
|
+
request: The Starlette request object
|
|
112
|
+
|
|
113
|
+
Returns:
|
|
114
|
+
Encryption context dict, or empty dict if header not present
|
|
115
|
+
|
|
116
|
+
Raises:
|
|
117
|
+
HTTPException: 422 if header is present but malformed
|
|
118
|
+
"""
|
|
119
|
+
header_value = request.headers.get("X-Encryption-Context")
|
|
120
|
+
if not header_value:
|
|
121
|
+
return {}
|
|
122
|
+
|
|
123
|
+
try:
|
|
124
|
+
decoded = base64.b64decode(header_value.encode())
|
|
125
|
+
context = orjson.loads(decoded)
|
|
126
|
+
if not isinstance(context, dict):
|
|
127
|
+
raise HTTPException(
|
|
128
|
+
status_code=422,
|
|
129
|
+
detail="Invalid X-Encryption-Context header: expected base64-encoded JSON object",
|
|
130
|
+
)
|
|
131
|
+
return context
|
|
132
|
+
except HTTPException:
|
|
133
|
+
raise
|
|
134
|
+
except Exception as e:
|
|
135
|
+
raise HTTPException(
|
|
136
|
+
status_code=422,
|
|
137
|
+
detail=f"Invalid X-Encryption-Context header: {e}",
|
|
138
|
+
) from e
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class EncryptionContextMiddleware(BaseHTTPMiddleware):
|
|
142
|
+
"""Middleware to extract and set encryption context from request headers.
|
|
143
|
+
|
|
144
|
+
If a @encryption.context handler is registered, it is called after extracting
|
|
145
|
+
the initial context from the X-Encryption-Context header. The handler receives
|
|
146
|
+
the authenticated user and can derive encryption context from auth (e.g., JWT claims).
|
|
147
|
+
"""
|
|
148
|
+
|
|
149
|
+
async def dispatch(self, request: Request, call_next):
|
|
150
|
+
context_dict = extract_encryption_context(request)
|
|
151
|
+
|
|
152
|
+
# Call context handler if registered (to derive context from auth)
|
|
153
|
+
encryption_instance = get_custom_encryption_instance()
|
|
154
|
+
if encryption_instance and encryption_instance._context_handler:
|
|
155
|
+
user = request.scope.get("user")
|
|
156
|
+
if user:
|
|
157
|
+
initial_ctx = EncryptionContext(
|
|
158
|
+
model=None, field=None, metadata=context_dict
|
|
159
|
+
)
|
|
160
|
+
try:
|
|
161
|
+
context_dict = await encryption_instance._context_handler(
|
|
162
|
+
user, initial_ctx
|
|
163
|
+
)
|
|
164
|
+
except Exception as e:
|
|
165
|
+
await logger.aexception(
|
|
166
|
+
"Error in encryption context handler", exc_info=e
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
set_encryption_context(context_dict)
|
|
170
|
+
request.state.encryption_context = context_dict
|
|
171
|
+
response = await call_next(request)
|
|
172
|
+
return response
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
class DoubleEncryptionError(Exception):
|
|
176
|
+
"""Raised when attempting to encrypt data that is already encrypted.
|
|
177
|
+
|
|
178
|
+
This typically indicates a bug where encrypted data is being passed through
|
|
179
|
+
the encryption pipeline again, which would corrupt the data.
|
|
180
|
+
"""
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
async def encrypt_json_if_needed(
|
|
184
|
+
data: dict[str, Any] | None,
|
|
185
|
+
encryption_instance: JsonEncryptionWrapper | AesEncryptionInstance | None,
|
|
186
|
+
model_type: ModelType,
|
|
187
|
+
field: str | None = None,
|
|
188
|
+
) -> dict[str, Any] | None:
|
|
189
|
+
"""Encrypt JSON data dict if encryption is configured.
|
|
190
|
+
|
|
191
|
+
Uses a unified interface where both custom (via wrapper) and AES encryption
|
|
192
|
+
implement the same encryptor interface (get_json_encryptor). The wrapper
|
|
193
|
+
handles key validation and context storage internally.
|
|
194
|
+
|
|
195
|
+
Args:
|
|
196
|
+
data: The plaintext data dict
|
|
197
|
+
encryption_instance: The encryption instance (wrapped custom, AES, or None)
|
|
198
|
+
model_type: The type of model (e.g., "thread", "assistant", "run")
|
|
199
|
+
field: The specific field being encrypted (e.g., "metadata", "context")
|
|
200
|
+
|
|
201
|
+
Returns:
|
|
202
|
+
Encrypted data dict with stored context, or original if no encryption configured
|
|
203
|
+
|
|
204
|
+
Raises:
|
|
205
|
+
EncryptionKeyError: If the encryptor adds or removes keys (violates key preservation)
|
|
206
|
+
DoubleEncryptionError: If data already has encryption context marker (already encrypted)
|
|
207
|
+
"""
|
|
208
|
+
if data is None:
|
|
209
|
+
return data
|
|
210
|
+
|
|
211
|
+
# Early return if no encryption configured (avoid unnecessary context lookups)
|
|
212
|
+
if encryption_instance is None:
|
|
213
|
+
return data
|
|
214
|
+
|
|
215
|
+
# Safety check: detect if data is already encrypted to prevent double encryption.
|
|
216
|
+
# Both AES and custom encryption use __encryption_context__ marker.
|
|
217
|
+
if ENCRYPTION_CONTEXT_KEY in data:
|
|
218
|
+
raise DoubleEncryptionError(
|
|
219
|
+
f"Attempted to encrypt data that is already encrypted (has {ENCRYPTION_CONTEXT_KEY}). "
|
|
220
|
+
f"model_type={model_type}, field={field}. "
|
|
221
|
+
f"This indicates a bug where encrypted data is being re-encrypted. "
|
|
222
|
+
f"Ensure data is decrypted before re-encrypting."
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
# Get encryptor from the instance (works for both custom wrapper and AES)
|
|
226
|
+
encryptor = encryption_instance.get_json_encryptor(model_type)
|
|
227
|
+
if encryptor is not None:
|
|
228
|
+
# Prepare data for encryption by serializing non-JSON-serializable objects
|
|
229
|
+
# (e.g., BaseUser in langgraph_auth_user)
|
|
230
|
+
data = _prepare_data_for_encryption(data)
|
|
231
|
+
|
|
232
|
+
# Build context for SDK interface (AES ignores this, custom uses it)
|
|
233
|
+
context_dict = get_encryption_context()
|
|
234
|
+
if LANGGRAPH_ENCRYPTION:
|
|
235
|
+
ctx = EncryptionContext(
|
|
236
|
+
model=model_type, field=field, metadata=context_dict
|
|
237
|
+
)
|
|
238
|
+
else:
|
|
239
|
+
ctx = None # AES doesn't need the EncryptionContext
|
|
240
|
+
|
|
241
|
+
# The encryptor handles key validation and context storage internally
|
|
242
|
+
encrypted = await encryptor(ctx, data)
|
|
243
|
+
|
|
244
|
+
await logger.adebug(
|
|
245
|
+
"Encrypted JSON data",
|
|
246
|
+
model_type=model_type,
|
|
247
|
+
field=field,
|
|
248
|
+
encryption_type="aes_only"
|
|
249
|
+
if isinstance(encryption_instance, AesEncryptionInstance)
|
|
250
|
+
else "custom_with_aes_migration",
|
|
251
|
+
)
|
|
252
|
+
return encrypted
|
|
253
|
+
|
|
254
|
+
# No JSON encryption configured, but store context for blob encryption
|
|
255
|
+
# This allows the worker to extract context even when JSON encryption is disabled
|
|
256
|
+
context_dict = get_encryption_context()
|
|
257
|
+
if context_dict and isinstance(data, dict):
|
|
258
|
+
data = dict(data) # shallow copy to avoid mutating input
|
|
259
|
+
data[BLOB_ENCRYPTION_CONTEXT_KEY] = context_dict
|
|
260
|
+
return data
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def extract_blob_encryption_context(
|
|
264
|
+
data: dict[str, Any] | None,
|
|
265
|
+
) -> dict[str, Any] | None:
|
|
266
|
+
"""Extract blob encryption context from a data dict.
|
|
267
|
+
|
|
268
|
+
This is used by the worker to extract the encryption context needed for
|
|
269
|
+
blob encryption during checkpoint serialization.
|
|
270
|
+
|
|
271
|
+
Checks both __blob_encryption_context__ (for blob-only encryption) and
|
|
272
|
+
__encryption_context__ (from JSON encryption, backward compatibility).
|
|
273
|
+
|
|
274
|
+
Args:
|
|
275
|
+
data: The data dict that may contain an encryption context
|
|
276
|
+
|
|
277
|
+
Returns:
|
|
278
|
+
The parsed encryption context dict, or None if not present
|
|
279
|
+
"""
|
|
280
|
+
if data is None:
|
|
281
|
+
return None
|
|
282
|
+
|
|
283
|
+
# Prefer __blob_encryption_context__ (explicit blob context)
|
|
284
|
+
# Fall back to __encryption_context__ (from JSON encryption, backward compat)
|
|
285
|
+
return data.get(BLOB_ENCRYPTION_CONTEXT_KEY) or data.get(ENCRYPTION_CONTEXT_KEY)
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
async def decrypt_json_if_needed(
|
|
289
|
+
data: dict[str, Any] | None,
|
|
290
|
+
encryption_instance: JsonEncryptionWrapper | AesEncryptionInstance | None,
|
|
291
|
+
model_type: ModelType,
|
|
292
|
+
field: str | None = None,
|
|
293
|
+
) -> dict[str, Any] | None:
|
|
294
|
+
"""Decrypt JSON data dict based on encryption markers.
|
|
295
|
+
|
|
296
|
+
Routes to the appropriate decryptor based on the type in __encryption_context__:
|
|
297
|
+
1. If marker present with AES type → use AES decryptor
|
|
298
|
+
2. If marker present without AES type → use custom decryptor
|
|
299
|
+
3. No marker → plaintext (return unchanged)
|
|
300
|
+
|
|
301
|
+
The routing logic is handled internally by the wrapper/instance:
|
|
302
|
+
- JsonEncryptionWrapper handles routing for custom + AES migration
|
|
303
|
+
- AesEncryptionInstance handles AES-only decryption
|
|
304
|
+
|
|
305
|
+
Args:
|
|
306
|
+
data: The data dict (encrypted or plaintext)
|
|
307
|
+
encryption_instance: The effective encryption instance (wrapper or AES-only)
|
|
308
|
+
model_type: The type of model (e.g., "thread", "assistant", "run")
|
|
309
|
+
field: The specific field being decrypted (e.g., "metadata", "context")
|
|
310
|
+
|
|
311
|
+
Returns:
|
|
312
|
+
Decrypted data dict (without reserved keys), or original if not encrypted
|
|
313
|
+
|
|
314
|
+
Raises:
|
|
315
|
+
DecryptorMissingError: If data is encrypted but required decryptor is not configured
|
|
316
|
+
EncryptionRoutingError: If markers are inconsistent (e.g., AES values with custom marker)
|
|
317
|
+
"""
|
|
318
|
+
if data is None:
|
|
319
|
+
return data
|
|
320
|
+
|
|
321
|
+
# No encryption configured
|
|
322
|
+
if encryption_instance is None:
|
|
323
|
+
# If data has encryption marker but no decryptor, raise error
|
|
324
|
+
if ENCRYPTION_CONTEXT_KEY in data:
|
|
325
|
+
context_dict = data[ENCRYPTION_CONTEXT_KEY]
|
|
326
|
+
if is_aes_encryption_context(context_dict):
|
|
327
|
+
raise DecryptorMissingError(
|
|
328
|
+
f"Data has AES encryption marker but LANGGRAPH_AES_KEY is not configured "
|
|
329
|
+
f"for {model_type}.{field}"
|
|
330
|
+
)
|
|
331
|
+
raise DecryptorMissingError(
|
|
332
|
+
f"Data contains custom encryption marker but no encryption instance is configured "
|
|
333
|
+
f"for {model_type}.{field}"
|
|
334
|
+
)
|
|
335
|
+
return strip_encryption_metadata(data)
|
|
336
|
+
|
|
337
|
+
# Get decryptor - the wrapper/instance handles routing (AES/custom/plaintext) internally
|
|
338
|
+
decryptor = encryption_instance.get_json_decryptor(model_type)
|
|
339
|
+
|
|
340
|
+
# Build context for SDK interface (AES ignores this, custom uses it)
|
|
341
|
+
context_dict = data.get(ENCRYPTION_CONTEXT_KEY, {})
|
|
342
|
+
if LANGGRAPH_ENCRYPTION:
|
|
343
|
+
ctx = EncryptionContext(model=model_type, field=field, metadata=context_dict)
|
|
344
|
+
else:
|
|
345
|
+
ctx = None
|
|
346
|
+
|
|
347
|
+
decrypted = await decryptor(ctx, data)
|
|
348
|
+
|
|
349
|
+
await logger.adebug(
|
|
350
|
+
"Decrypted JSON data",
|
|
351
|
+
model_type=model_type,
|
|
352
|
+
field=field,
|
|
353
|
+
encryption_type="aes_only"
|
|
354
|
+
if isinstance(encryption_instance, AesEncryptionInstance)
|
|
355
|
+
else "custom_with_aes_migration",
|
|
356
|
+
)
|
|
357
|
+
return decrypted
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
async def _decrypt_field(
|
|
361
|
+
obj: dict[str, Any],
|
|
362
|
+
field_name: str,
|
|
363
|
+
encryption_instance: JsonEncryptionWrapper | AesEncryptionInstance | None,
|
|
364
|
+
model_type: ModelType,
|
|
365
|
+
) -> tuple[str, Any]:
|
|
366
|
+
"""Decrypt a single field, returning (field_name, decrypted_value).
|
|
367
|
+
|
|
368
|
+
Fields defined in NESTED_ENCRYPTED_SUBFIELDS have their subfields decrypted
|
|
369
|
+
recursively (e.g., run.kwargs.config.configurable).
|
|
370
|
+
|
|
371
|
+
Returns (field_name, None) if field doesn't exist or is falsy.
|
|
372
|
+
"""
|
|
373
|
+
if not obj.get(field_name):
|
|
374
|
+
return (field_name, obj.get(field_name))
|
|
375
|
+
|
|
376
|
+
value = obj[field_name]
|
|
377
|
+
# Database fields come back as either:
|
|
378
|
+
# - dict: already parsed JSONB (psycopg JSON adapter)
|
|
379
|
+
# - bytes/bytearray/memoryview/str: raw JSON to parse (psycopg binary mode)
|
|
380
|
+
# - Fragment: wrapper around bytes (used by serde layer)
|
|
381
|
+
if isinstance(value, dict):
|
|
382
|
+
pass # already parsed
|
|
383
|
+
elif isinstance(value, (bytes, bytearray, memoryview, str, Fragment)):
|
|
384
|
+
value = json_loads(value)
|
|
385
|
+
else:
|
|
386
|
+
raise TypeError(
|
|
387
|
+
f"Cannot decrypt field '{field_name}': expected dict or JSON-serialized "
|
|
388
|
+
f"bytes/str, got {type(value).__name__}"
|
|
389
|
+
)
|
|
390
|
+
|
|
391
|
+
decrypted = await decrypt_json_if_needed(
|
|
392
|
+
value, encryption_instance, model_type, field=field_name
|
|
393
|
+
)
|
|
394
|
+
|
|
395
|
+
# Recursively decrypt subfields defined in NESTED_ENCRYPTED_SUBFIELDS.
|
|
396
|
+
# This handles nested structures like run.kwargs.config.configurable where each
|
|
397
|
+
# level needs individual encryption to preserve structure for SQL JSONB operations.
|
|
398
|
+
nested_key = (model_type, field_name)
|
|
399
|
+
if nested_key in NESTED_ENCRYPTED_SUBFIELDS and decrypted is not None:
|
|
400
|
+
results = await asyncio.gather(
|
|
401
|
+
*[
|
|
402
|
+
_decrypt_field(decrypted, sf, encryption_instance, model_type)
|
|
403
|
+
for sf in NESTED_ENCRYPTED_SUBFIELDS[nested_key]
|
|
404
|
+
if sf in decrypted
|
|
405
|
+
]
|
|
406
|
+
)
|
|
407
|
+
for sf_name, sf_value in results:
|
|
408
|
+
decrypted[sf_name] = sf_value
|
|
409
|
+
|
|
410
|
+
return (field_name, decrypted)
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
async def _decrypt_object(
|
|
414
|
+
obj: dict[str, Any],
|
|
415
|
+
model_type: ModelType,
|
|
416
|
+
fields: list[str],
|
|
417
|
+
encryption_instance: JsonEncryptionWrapper | AesEncryptionInstance | None,
|
|
418
|
+
) -> None:
|
|
419
|
+
"""Decrypt all specified fields in a single object (in parallel).
|
|
420
|
+
|
|
421
|
+
Only processes fields that exist in the object to avoid adding new fields.
|
|
422
|
+
"""
|
|
423
|
+
results = await asyncio.gather(
|
|
424
|
+
*[
|
|
425
|
+
_decrypt_field(obj, f, encryption_instance, model_type)
|
|
426
|
+
for f in fields
|
|
427
|
+
if f in obj
|
|
428
|
+
]
|
|
429
|
+
)
|
|
430
|
+
for field_name, value in results:
|
|
431
|
+
obj[field_name] = value
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
async def decrypt_response(
|
|
435
|
+
obj: Mapping[str, Any],
|
|
436
|
+
model_type: ModelType,
|
|
437
|
+
fields: list[str],
|
|
438
|
+
) -> dict[str, Any]:
|
|
439
|
+
"""Decrypt specified fields in a response object (from database).
|
|
440
|
+
|
|
441
|
+
IMPORTANT: This function only parses and decrypts fields when encryption is
|
|
442
|
+
enabled (custom or AES). When encryption is disabled, the original object is
|
|
443
|
+
returned as-is (no copy, no parsing). This is intentional: some fields can be
|
|
444
|
+
very large, and we want to avoid parsing overhead when the bytes can be passed
|
|
445
|
+
through directly to the response. Callers that need parsed dicts regardless of
|
|
446
|
+
encryption state should use json_loads() on the fields they need to inspect.
|
|
447
|
+
|
|
448
|
+
When encryption IS enabled, this parses bytes/memoryview/Fragment to dicts
|
|
449
|
+
before decryption, and returns a shallow copy with decrypted fields.
|
|
450
|
+
|
|
451
|
+
Fields defined in NESTED_ENCRYPTED_SUBFIELDS have their subfields decrypted
|
|
452
|
+
recursively (e.g., config.configurable, config.metadata).
|
|
453
|
+
|
|
454
|
+
Args:
|
|
455
|
+
obj: Single mapping from database (fields may be bytes or already-parsed dicts, not mutated)
|
|
456
|
+
model_type: Type identifier passed to EncryptionContext.model (e.g., "run", "cron", "thread")
|
|
457
|
+
fields: List of field names to decrypt (e.g., ["metadata", "kwargs"])
|
|
458
|
+
|
|
459
|
+
Returns:
|
|
460
|
+
Original object if encryption disabled, otherwise new dict with decrypted fields
|
|
461
|
+
"""
|
|
462
|
+
encryption_instance = get_encryption()
|
|
463
|
+
if encryption_instance is None:
|
|
464
|
+
return obj # type: ignore[return-value]
|
|
465
|
+
|
|
466
|
+
result = dict(obj)
|
|
467
|
+
await _decrypt_object(result, model_type, fields, encryption_instance)
|
|
468
|
+
return result
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
async def decrypt_responses(
|
|
472
|
+
objects: Sequence[Mapping[str, Any]],
|
|
473
|
+
model_type: ModelType,
|
|
474
|
+
fields: list[str],
|
|
475
|
+
) -> list[dict[str, Any]]:
|
|
476
|
+
"""Decrypt specified fields in multiple response objects (from database).
|
|
477
|
+
|
|
478
|
+
IMPORTANT: This function only parses and decrypts fields when encryption is
|
|
479
|
+
enabled (custom or AES). When encryption is disabled, the original sequence is
|
|
480
|
+
returned as-is (no copies, no parsing). This is intentional: some fields can be
|
|
481
|
+
very large, and we want to avoid parsing overhead when the bytes can be passed
|
|
482
|
+
through directly to the response. Callers that need parsed dicts regardless of
|
|
483
|
+
encryption state should use json_loads() on the fields they need to inspect.
|
|
484
|
+
|
|
485
|
+
When encryption IS enabled, this parses bytes/memoryview/Fragment to dicts
|
|
486
|
+
before decryption, and returns a new list of shallow copies with decrypted fields.
|
|
487
|
+
|
|
488
|
+
Fields defined in NESTED_ENCRYPTED_SUBFIELDS have their subfields decrypted
|
|
489
|
+
recursively (e.g., config.configurable, config.metadata).
|
|
490
|
+
|
|
491
|
+
Args:
|
|
492
|
+
objects: Sequence of mappings from database (fields may be bytes or already-parsed dicts, not mutated)
|
|
493
|
+
model_type: Type identifier passed to EncryptionContext.model (e.g., "run", "cron", "thread")
|
|
494
|
+
fields: List of field names to decrypt (e.g., ["metadata", "kwargs"])
|
|
495
|
+
|
|
496
|
+
Returns:
|
|
497
|
+
Original sequence if encryption disabled, otherwise new list with decrypted fields
|
|
498
|
+
"""
|
|
499
|
+
encryption_instance = get_encryption()
|
|
500
|
+
if encryption_instance is None:
|
|
501
|
+
return objects # type: ignore[return-value]
|
|
502
|
+
|
|
503
|
+
results = [dict(obj) for obj in objects]
|
|
504
|
+
await asyncio.gather(
|
|
505
|
+
*[
|
|
506
|
+
_decrypt_object(result, model_type, fields, encryption_instance)
|
|
507
|
+
for result in results
|
|
508
|
+
]
|
|
509
|
+
)
|
|
510
|
+
return results
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
async def _encrypt_field(
|
|
514
|
+
data: Mapping[str, Any],
|
|
515
|
+
field_name: str,
|
|
516
|
+
encryption_instance: JsonEncryptionWrapper | AesEncryptionInstance | None,
|
|
517
|
+
model_type: ModelType,
|
|
518
|
+
) -> tuple[str, Any]:
|
|
519
|
+
"""Encrypt a single field, returning (field_name, encrypted_value).
|
|
520
|
+
|
|
521
|
+
Fields defined in NESTED_ENCRYPTED_SUBFIELDS have their subfields extracted
|
|
522
|
+
and encrypted separately, then added back. This preserves the nested structure
|
|
523
|
+
for SQL JSONB operations while encrypting each level individually.
|
|
524
|
+
|
|
525
|
+
Returns (field_name, None) if field doesn't exist or is None.
|
|
526
|
+
"""
|
|
527
|
+
if field_name not in data or data[field_name] is None:
|
|
528
|
+
return (field_name, data.get(field_name))
|
|
529
|
+
|
|
530
|
+
field_data = data[field_name]
|
|
531
|
+
|
|
532
|
+
# Check if this field has subfields that need separate encryption
|
|
533
|
+
nested_key = (model_type, field_name)
|
|
534
|
+
subfields_to_extract: dict[str, Any] = {}
|
|
535
|
+
|
|
536
|
+
if nested_key in NESTED_ENCRYPTED_SUBFIELDS:
|
|
537
|
+
if not isinstance(field_data, dict):
|
|
538
|
+
raise TypeError(
|
|
539
|
+
f"'{field_name}' must be a dict for encryption, got {type(field_data).__name__}"
|
|
540
|
+
)
|
|
541
|
+
for subfield in NESTED_ENCRYPTED_SUBFIELDS[nested_key]:
|
|
542
|
+
subfield_value = field_data.get(subfield)
|
|
543
|
+
if subfield_value is not None and not isinstance(subfield_value, dict):
|
|
544
|
+
raise TypeError(
|
|
545
|
+
f"'{subfield}' in '{field_name}' must be a dict for encryption, "
|
|
546
|
+
f"got {type(subfield_value).__name__}"
|
|
547
|
+
)
|
|
548
|
+
if subfield_value:
|
|
549
|
+
subfields_to_extract[subfield] = subfield_value
|
|
550
|
+
|
|
551
|
+
if subfields_to_extract:
|
|
552
|
+
# Create a copy without subfields for the first encryption pass
|
|
553
|
+
field_data = {
|
|
554
|
+
k: v for k, v in field_data.items() if k not in subfields_to_extract
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
encrypted = await encrypt_json_if_needed(
|
|
558
|
+
field_data,
|
|
559
|
+
encryption_instance,
|
|
560
|
+
model_type,
|
|
561
|
+
field=field_name,
|
|
562
|
+
)
|
|
563
|
+
|
|
564
|
+
# Recursively encrypt extracted subfields and add them back
|
|
565
|
+
if subfields_to_extract and isinstance(encrypted, dict):
|
|
566
|
+
subfield_results = await asyncio.gather(
|
|
567
|
+
*[
|
|
568
|
+
_encrypt_field(
|
|
569
|
+
{sf_name: sf_value},
|
|
570
|
+
sf_name,
|
|
571
|
+
encryption_instance,
|
|
572
|
+
model_type,
|
|
573
|
+
)
|
|
574
|
+
for sf_name, sf_value in subfields_to_extract.items()
|
|
575
|
+
]
|
|
576
|
+
)
|
|
577
|
+
for sf_name, sf_encrypted in subfield_results:
|
|
578
|
+
encrypted[sf_name] = sf_encrypted
|
|
579
|
+
|
|
580
|
+
return (field_name, encrypted)
|
|
581
|
+
|
|
582
|
+
|
|
583
|
+
async def encrypt_request(
|
|
584
|
+
data: Mapping[str, Any],
|
|
585
|
+
model_type: ModelType,
|
|
586
|
+
fields: list[str],
|
|
587
|
+
) -> dict[str, Any]:
|
|
588
|
+
"""Encrypt specified fields in request data before passing to ops layer (in parallel).
|
|
589
|
+
|
|
590
|
+
This is a generic helper that handles encryption for any object type.
|
|
591
|
+
It uses the ContextVar to get encryption context (set by middleware or endpoint).
|
|
592
|
+
|
|
593
|
+
When encryption is disabled (neither custom nor AES), the original data is
|
|
594
|
+
returned as-is (no copy). When encryption IS enabled, returns a shallow copy
|
|
595
|
+
with encrypted fields.
|
|
596
|
+
|
|
597
|
+
Fields defined in NESTED_ENCRYPTED_SUBFIELDS have their subfields encrypted
|
|
598
|
+
recursively (e.g., config.configurable, config.metadata).
|
|
599
|
+
|
|
600
|
+
Only processes fields that exist in the data to avoid adding new fields.
|
|
601
|
+
|
|
602
|
+
Args:
|
|
603
|
+
data: Request data mapping to encrypt (not mutated)
|
|
604
|
+
model_type: Type identifier passed to EncryptionContext.model (e.g., "run", "cron", "thread")
|
|
605
|
+
fields: List of field names to encrypt (e.g., ["metadata", "kwargs"])
|
|
606
|
+
|
|
607
|
+
Returns:
|
|
608
|
+
Original data if encryption disabled, otherwise new dict with encrypted fields
|
|
609
|
+
|
|
610
|
+
Example:
|
|
611
|
+
encrypted = await encrypt_request(
|
|
612
|
+
payload,
|
|
613
|
+
"run",
|
|
614
|
+
["metadata"]
|
|
615
|
+
)
|
|
616
|
+
"""
|
|
617
|
+
encryption_instance = get_encryption()
|
|
618
|
+
if encryption_instance is None:
|
|
619
|
+
return data # type: ignore[return-value]
|
|
620
|
+
|
|
621
|
+
result = dict(data)
|
|
622
|
+
encrypted_fields = await asyncio.gather(
|
|
623
|
+
*[
|
|
624
|
+
_encrypt_field(data, f, encryption_instance, model_type)
|
|
625
|
+
for f in fields
|
|
626
|
+
if f in data
|
|
627
|
+
]
|
|
628
|
+
)
|
|
629
|
+
for field_name, value in encrypted_fields:
|
|
630
|
+
result[field_name] = value
|
|
631
|
+
|
|
632
|
+
return result
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Shared encryption constants and utilities.
|
|
2
|
+
|
|
3
|
+
This module contains constants and helper functions used by both
|
|
4
|
+
custom encryption (via SDK) and built-in AES encryption.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import functools
|
|
10
|
+
from typing import TYPE_CHECKING, Any
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from langgraph_api.encryption.aes_json import AesEncryptionInstance
|
|
14
|
+
from langgraph_api.encryption.custom import JsonEncryptionWrapper
|
|
15
|
+
|
|
16
|
+
# Marker keys for encryption context storage
|
|
17
|
+
ENCRYPTION_CONTEXT_KEY = "__encryption_context__"
|
|
18
|
+
BLOB_ENCRYPTION_CONTEXT_KEY = "__blob_encryption_context__"
|
|
19
|
+
|
|
20
|
+
# Reserved keys that should never appear in user-facing responses
|
|
21
|
+
RESERVED_ENCRYPTION_KEYS = frozenset(
|
|
22
|
+
{ENCRYPTION_CONTEXT_KEY, BLOB_ENCRYPTION_CONTEXT_KEY}
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def strip_encryption_metadata(data: dict[str, Any]) -> dict[str, Any]:
|
|
27
|
+
"""Strip encryption-related keys from a data dict.
|
|
28
|
+
|
|
29
|
+
Used during decryption to remove internal markers before returning
|
|
30
|
+
data to callers.
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
data: Dict that may contain encryption marker keys
|
|
34
|
+
|
|
35
|
+
Returns:
|
|
36
|
+
New dict with marker keys removed
|
|
37
|
+
"""
|
|
38
|
+
return {k: v for k, v in data.items() if k not in RESERVED_ENCRYPTION_KEYS}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@functools.lru_cache(maxsize=1)
|
|
42
|
+
def get_encryption() -> JsonEncryptionWrapper | AesEncryptionInstance | None:
|
|
43
|
+
"""Get the effective encryption instance for JSON encryption.
|
|
44
|
+
|
|
45
|
+
Returns the cached encryption instance based on configuration:
|
|
46
|
+
- Custom + AES configured: JsonEncryptionWrapper (handles migration)
|
|
47
|
+
- AES only: AesEncryptionInstance
|
|
48
|
+
- Neither: None
|
|
49
|
+
"""
|
|
50
|
+
# Late import to avoid circular dependency
|
|
51
|
+
from langgraph_api.encryption.aes_json import get_aes_encryption_instance
|
|
52
|
+
from langgraph_api.encryption.custom import (
|
|
53
|
+
JsonEncryptionWrapper,
|
|
54
|
+
get_custom_encryption_instance,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
custom_instance = get_custom_encryption_instance()
|
|
58
|
+
aes = get_aes_encryption_instance()
|
|
59
|
+
|
|
60
|
+
if custom_instance:
|
|
61
|
+
# Wrap custom encryption with AES migration support (can decrypt old AES data)
|
|
62
|
+
return JsonEncryptionWrapper(custom_instance, aes)
|
|
63
|
+
return aes
|