Skip to content

Commit 94698c8

Browse files
cwlbraaclaude
andauthored
feat: custom encryption at rest (#6482)
**Description:** This PR adds the Python SDK types necessary for langgraph platform users to inject their own custom encryption-at-rest functions. See [docs PR](langchain-ai/docs#1715) for more details. note: this PR adds a starlette dev dependency so that custom encryption can access BaseUser information. **Issue:** required for LSD-172 **Dependencies:** - [depended upon by associated langgraph-api changes](langchain-ai/langgraph-api#1773 PR must merge before that one) - [docs PR](langchain-ai/docs#1715) **TODO:** - [x] move docs to docs repo - [x] bump package versions before merge --------- Signed-off-by: Connor Braa <cwlbraa@langchain.dev> Co-authored-by: Claude <noreply@anthropic.com>
1 parent 7d557cb commit 94698c8

File tree

13 files changed

+1036
-3
lines changed

13 files changed

+1036
-3
lines changed

libs/cli/langgraph_cli/config.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,7 @@ def validate_config(config: Config) -> Config:
154154
"env": config.get("env", {}),
155155
"store": config.get("store"),
156156
"auth": config.get("auth"),
157+
"encryption": config.get("encryption"),
157158
"http": config.get("http"),
158159
"checkpointer": config.get("checkpointer"),
159160
"ui": config.get("ui"),
@@ -228,6 +229,14 @@ def validate_config(config: Config) -> Config:
228229
f"Invalid auth.path format: '{auth_conf['path']}'. "
229230
"Must be in format './path/to/file.py:attribute_name'"
230231
)
232+
# Validate encryption config
233+
if encryption_conf := config.get("encryption"):
234+
if "path" in encryption_conf:
235+
if ":" not in encryption_conf["path"]:
236+
raise ValueError(
237+
f"Invalid encryption.path format: '{encryption_conf['path']}'. "
238+
"Must be in format './path/to/file.py:attribute_name'"
239+
)
231240
if http_conf := config.get("http"):
232241
if "app" in http_conf:
233242
if ":" not in http_conf["app"]:
@@ -614,6 +623,49 @@ def _update_auth_path(
614623
)
615624

616625

626+
def _update_encryption_path(
627+
config_path: pathlib.Path, config: Config, local_deps: LocalDeps
628+
) -> None:
629+
"""Update encryption.path to use Docker container paths."""
630+
encryption_conf = config.get("encryption")
631+
if not encryption_conf or not (path_str := encryption_conf.get("path")):
632+
return
633+
634+
module_str, sep, attr_str = path_str.partition(":")
635+
if not sep or not module_str.startswith("."):
636+
return # Already validated or absolute path
637+
638+
resolved = config_path.parent / module_str
639+
if not resolved.exists():
640+
raise FileNotFoundError(
641+
f"Encryption file not found: {resolved} (from {path_str})"
642+
)
643+
if not resolved.is_file():
644+
raise IsADirectoryError(f"Encryption path must be a file: {resolved}")
645+
646+
# Check faux packages first (higher priority)
647+
for faux_path, (_, destpath) in local_deps.faux_pkgs.items():
648+
if resolved.is_relative_to(faux_path):
649+
new_path = f"{destpath}/{resolved.relative_to(faux_path)}:{attr_str}"
650+
encryption_conf["path"] = new_path
651+
return
652+
653+
# Check real packages
654+
for real_path in local_deps.real_pkgs:
655+
if resolved.is_relative_to(real_path):
656+
new_path = (
657+
f"/deps/{real_path.name}/{resolved.relative_to(real_path)}:{attr_str}"
658+
)
659+
encryption_conf["path"] = new_path
660+
return
661+
662+
raise ValueError(
663+
f"Encryption file '{resolved}' not covered by dependencies.\n"
664+
"Add its parent directory to the 'dependencies' array in your config.\n"
665+
f"Current dependencies: {config['dependencies']}"
666+
)
667+
668+
617669
def _update_http_app_path(
618670
config_path: pathlib.Path, config: Config, local_deps: LocalDeps
619671
) -> None:
@@ -809,6 +861,8 @@ def python_config_to_docker(
809861
_update_graph_paths(config_path, config, local_deps)
810862
# Rewrite auth path, so it points to the correct location in the Docker container
811863
_update_auth_path(config_path, config, local_deps)
864+
# Rewrite encryption path, so it points to the correct location in the Docker container
865+
_update_encryption_path(config_path, config, local_deps)
812866
# Rewrite HTTP app path, so it points to the correct location in the Docker container
813867
_update_http_app_path(config_path, config, local_deps)
814868

@@ -899,6 +953,9 @@ def python_config_to_docker(
899953
if (auth_config := config.get("auth")) is not None:
900954
env_vars.append(f"ENV LANGGRAPH_AUTH='{json.dumps(auth_config)}'")
901955

956+
if (encryption_config := config.get("encryption")) is not None:
957+
env_vars.append(f"ENV LANGGRAPH_ENCRYPTION='{json.dumps(encryption_config)}'")
958+
902959
if (http_config := config.get("http")) is not None:
903960
env_vars.append(f"ENV LANGGRAPH_HTTP='{json.dumps(http_config)}'")
904961

@@ -1022,6 +1079,9 @@ def node_config_to_docker(
10221079
if (auth_config := config.get("auth")) is not None:
10231080
env_vars.append(f"ENV LANGGRAPH_AUTH='{json.dumps(auth_config)}'")
10241081

1082+
if (encryption_config := config.get("encryption")) is not None:
1083+
env_vars.append(f"ENV LANGGRAPH_ENCRYPTION='{json.dumps(encryption_config)}'")
1084+
10251085
if (http_config := config.get("http")) is not None:
10261086
env_vars.append(f"ENV LANGGRAPH_HTTP='{json.dumps(http_config)}'")
10271087

libs/cli/langgraph_cli/schemas.py

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -302,6 +302,27 @@ class AuthConfig(TypedDict, total=False):
302302
"""
303303

304304

305+
class EncryptionConfig(TypedDict, total=False):
306+
"""Configuration for custom at-rest encryption logic.
307+
308+
Allows you to implement custom encryption for sensitive data stored in the database,
309+
including metadata fields and checkpoint blobs.
310+
"""
311+
312+
path: str
313+
"""Required. Path to an instance of the Encryption() class that implements custom encryption handlers.
314+
315+
Format: "path/to/file.py:my_encryption"
316+
317+
Example:
318+
{
319+
"encryption": {
320+
"path": "./encryption.py:my_encryption"
321+
}
322+
}
323+
"""
324+
325+
305326
class CorsConfig(TypedDict, total=False):
306327
"""Specifies Cross-Origin Resource Sharing (CORS) rules for your server.
307328
@@ -577,10 +598,16 @@ class Config(TypedDict, total=False):
577598
"""
578599

579600
auth: AuthConfig | None
580-
"""Optional. Custom authentication config, including the path to your Python auth logic and
601+
"""Optional. Custom authentication config, including the path to your Python auth logic and
581602
the OpenAPI security definitions it uses.
582603
"""
583604

605+
encryption: EncryptionConfig | None
606+
"""Optional. Custom at-rest encryption config, including the path to your Python encryption logic.
607+
608+
Allows you to implement custom encryption for sensitive data stored in the database.
609+
"""
610+
584611
http: HttpConfig | None
585612
"""Optional. Configuration for the built-in HTTP server, controlling which custom routes are exposed
586613
and how cross-origin requests are handled.
@@ -603,6 +630,7 @@ class Config(TypedDict, total=False):
603630
"StoreConfig",
604631
"CheckpointerConfig",
605632
"AuthConfig",
633+
"EncryptionConfig",
606634
"HttpConfig",
607635
"MiddlewareOrders",
608636
"Distros",

libs/cli/schemas/schema.json

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,17 @@
9999
},
100100
"description": "Optional. Additional Docker instructions that will be appended to your base Dockerfile.\n\nUseful for installing OS packages, setting environment variables, etc."
101101
},
102+
"encryption": {
103+
"anyOf": [
104+
{
105+
"$ref": "#/$defs/EncryptionConfig"
106+
},
107+
{
108+
"type": "null"
109+
}
110+
],
111+
"description": "Optional. Custom at-rest encryption config, including the path to your Python encryption logic.\n\nAllows you to implement custom encryption for sensitive data stored in the database.\n"
112+
},
102113
"env": {
103114
"anyOf": [
104115
{
@@ -292,6 +303,17 @@
292303
},
293304
"description": "Optional. Additional Docker instructions that will be appended to your base Dockerfile.\n\nUseful for installing OS packages, setting environment variables, etc."
294305
},
306+
"encryption": {
307+
"anyOf": [
308+
{
309+
"$ref": "#/$defs/EncryptionConfig"
310+
},
311+
{
312+
"type": "null"
313+
}
314+
],
315+
"description": "Optional. Custom at-rest encryption config, including the path to your Python encryption logic.\n\nAllows you to implement custom encryption for sensitive data stored in the database.\n"
316+
},
295317
"env": {
296318
"anyOf": [
297319
{
@@ -592,6 +614,17 @@
592614
},
593615
"required": []
594616
},
617+
"EncryptionConfig": {
618+
"title": "EncryptionConfig",
619+
"description": "Configuration for custom at-rest encryption logic.\n\n Allows you to implement custom encryption for sensitive data stored in the database,\n including metadata fields and checkpoint blobs.",
620+
"type": "object",
621+
"properties": {
622+
"path": {
623+
"type": "string"
624+
}
625+
},
626+
"required": []
627+
},
595628
"HttpConfig": {
596629
"title": "HttpConfig",
597630
"description": "Configuration for the built-in HTTP server that powers your deployment's routes and endpoints.",

libs/cli/schemas/schema.v0.json

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,17 @@
9999
},
100100
"description": "Optional. Additional Docker instructions that will be appended to your base Dockerfile.\n\nUseful for installing OS packages, setting environment variables, etc."
101101
},
102+
"encryption": {
103+
"anyOf": [
104+
{
105+
"$ref": "#/$defs/EncryptionConfig"
106+
},
107+
{
108+
"type": "null"
109+
}
110+
],
111+
"description": "Optional. Custom at-rest encryption config, including the path to your Python encryption logic.\n\nAllows you to implement custom encryption for sensitive data stored in the database.\n"
112+
},
102113
"env": {
103114
"anyOf": [
104115
{
@@ -292,6 +303,17 @@
292303
},
293304
"description": "Optional. Additional Docker instructions that will be appended to your base Dockerfile.\n\nUseful for installing OS packages, setting environment variables, etc."
294305
},
306+
"encryption": {
307+
"anyOf": [
308+
{
309+
"$ref": "#/$defs/EncryptionConfig"
310+
},
311+
{
312+
"type": "null"
313+
}
314+
],
315+
"description": "Optional. Custom at-rest encryption config, including the path to your Python encryption logic.\n\nAllows you to implement custom encryption for sensitive data stored in the database.\n"
316+
},
295317
"env": {
296318
"anyOf": [
297319
{
@@ -592,6 +614,17 @@
592614
},
593615
"required": []
594616
},
617+
"EncryptionConfig": {
618+
"title": "EncryptionConfig",
619+
"description": "Configuration for custom at-rest encryption logic.\n\n Allows you to implement custom encryption for sensitive data stored in the database,\n including metadata fields and checkpoint blobs.",
620+
"type": "object",
621+
"properties": {
622+
"path": {
623+
"type": "string"
624+
}
625+
},
626+
"required": []
627+
},
595628
"HttpConfig": {
596629
"title": "HttpConfig",
597630
"description": "Configuration for the built-in HTTP server that powers your deployment's routes and endpoints.",

libs/cli/tests/unit_tests/test_config.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ def test_validate_config():
4848
"env": {},
4949
"store": None,
5050
"auth": None,
51+
"encryption": None,
5152
"checkpointer": None,
5253
"http": None,
5354
"ui": None,
@@ -74,6 +75,7 @@ def test_validate_config():
7475
"env": env,
7576
"store": None,
7677
"auth": None,
78+
"encryption": None,
7779
"checkpointer": None,
7880
"http": None,
7981
"ui": None,
@@ -748,6 +750,57 @@ def test_config_to_docker_nodejs():
748750
assert additional_contexts == {}
749751

750752

753+
def test_config_to_docker_python_encryption():
754+
# Test that encryption config is included in validation
755+
graphs = {"agent": "./agent.py:graph"}
756+
validated = validate_config(
757+
{
758+
"python_version": "3.11",
759+
"graphs": graphs,
760+
"dependencies": ["."],
761+
"encryption": {"path": "./encryption.py:encryption"},
762+
}
763+
)
764+
765+
# Verify that encryption config is preserved after validation
766+
assert validated.get("encryption") is not None
767+
assert validated["encryption"]["path"] == "./encryption.py:encryption"
768+
769+
770+
def test_config_to_docker_python_encryption_bad_path():
771+
# Test that invalid encryption path format raises ValueError
772+
graphs = {"agent": "./agent.py:graph"}
773+
with pytest.raises(ValueError, match="Invalid encryption.path format"):
774+
validate_config(
775+
{
776+
"python_version": "3.11",
777+
"graphs": graphs,
778+
"dependencies": ["."],
779+
"encryption": {"path": "./encryption.py"}, # Missing :attribute
780+
}
781+
)
782+
783+
784+
def test_config_to_docker_python_encryption_formatted():
785+
# Test that encryption config is properly formatted in Docker output
786+
graphs = {"agent": "./graphs/agent.py:graph"}
787+
actual_docker_stdin, additional_contexts = config_to_docker(
788+
PATH_TO_CONFIG,
789+
validate_config(
790+
{
791+
"python_version": "3.11",
792+
"dependencies": ["."],
793+
"graphs": graphs,
794+
"encryption": {"path": "./agent.py:my_encryption"},
795+
}
796+
),
797+
"langchain/langgraph-api",
798+
)
799+
# Verify that LANGGRAPH_ENCRYPTION is in the docker output with the correct path
800+
assert "LANGGRAPH_ENCRYPTION=" in actual_docker_stdin
801+
assert "/deps/outer-unit_tests/unit_tests/agent.py:my_encryption" in actual_docker_stdin
802+
803+
751804
def test_config_to_docker_nodejs_internal_docker_tag():
752805
graphs = {"agent": "./graphs/agent.js:graph"}
753806
actual_docker_stdin, additional_contexts = config_to_docker(

libs/langgraph/uv.lock

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

libs/prebuilt/uv.lock

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
from langgraph_sdk.auth import Auth
22
from langgraph_sdk.client import get_client, get_sync_client
3+
from langgraph_sdk.encryption import Encryption
4+
from langgraph_sdk.encryption.types import EncryptionContext
35

4-
__version__ = "0.2.13"
6+
__version__ = "0.2.14"
57

6-
__all__ = ["Auth", "get_client", "get_sync_client"]
8+
__all__ = ["Auth", "Encryption", "EncryptionContext", "get_client", "get_sync_client"]

0 commit comments

Comments
 (0)