Tenant isolation and per-tenant usage metering for LangGraph checkpointers and stores
Project description
langgraph-tenancy
Tenant isolation for LangGraph persistence — as a drop-in wrapper.
Using LangGraph.js? Same package, same guarantees: ac12644/langgraph-tenancy-js · npm
LangGraph's own threat model says it plainly:
Checkpoint savers index by
thread_id. Without application-level auth, any caller with a valid thread_id can access that thread's state. [...] Users embedding LangGraph directly must implement their own access controls.
If you run a multi-tenant product on open-source LangGraph, the only thing between Customer A's agent state and Customer B's is a query filter in your application code. This package replaces that convention with enforcement — plus the operational surface a multi-tenant product needs: per-tenant usage metering, quota enforcement, GDPR-style erasure, migration of pre-tenancy data, and observability hooks for every denial.
Install
pip install langgraph-tenancy
Usage
Wrap your existing checkpointer and store. Nothing else changes.
from langgraph_tenancy import (
TenantScopedCheckpointer,
TenantScopedStore,
InMemoryUsageLedger,
)
ledger = InMemoryUsageLedger()
checkpointer = TenantScopedCheckpointer(PostgresSaver(...), usage_ledger=ledger)
store = TenantScopedStore(InMemoryStore())
graph = builder.compile(checkpointer=checkpointer, store=store)
# tenant_id is now REQUIRED on every invocation
graph.invoke(
{"messages": ["hello"]},
config={"configurable": {"thread_id": "t1", "tenant_id": "acme"}},
)
# free per-tenant token metering, extracted from checkpointed messages
ledger.totals("acme") # TenantUsage(input_tokens=..., output_tokens=..., by_model={...})
What it enforces
| Raw LangGraph behavior | With langgraph-tenancy |
|---|---|
Any caller with a thread_id reads that thread |
Threads are physically keyed tenant::thread; wrong-thread_id bugs cannot cross tenants |
| Missing filter → silent unscoped query | Missing tenant_id → TenantRequiredError, nothing read or written |
Missing thread_id → writes keyed "None" |
Refused with a loud TenancyError |
checkpointer.list(None) enumerates every tenant's threads |
Refused with UnscopedAccessError; list() with a tenant (and no thread) enumerates only that tenant's threads |
| Store namespaces are convention; any node can read any namespace | Every op is rooted at the tenant segment, resolved from the run config automatically |
delete_thread("t1") deletes whoever owns t1 |
Requires an explicit for_tenant("acme").delete_thread("t1") handle |
usage_metadata buried in checkpoint blobs, unqueryable |
Aggregated per tenant (and per model), deduped by message id |
| Tenant ids are arbitrary strings | Restricted to [A-Za-z0-9_-]{1,64} — safe in every backend's key/namespace encoding |
Quotas
Give the checkpointer per-tenant limits and it enforces them at the persistence boundary — an over-quota tenant's next run fails at its first checkpoint, before any model call spends money:
from langgraph_tenancy import TenantLimits, QuotaExceededError
checkpointer = TenantScopedCheckpointer(
inner,
usage_ledger=ledger, # quota reads current usage from ledger.totals()
quota_limits=lambda tenant_id: plans.lookup(tenant_id),
# e.g. TenantLimits(max_total_tokens=1_000_000, max_messages=10_000)
# return None for "no limits"
)
Semantics: check-then-write. The turn that crosses a limit still completes
(that spend already happened and must not be lost); every run started after
that fails with QuotaExceededError. Runs are never killed mid-flight.
Supply quota_usage= to read usage from your own billing system instead of
the ledger.
Usage metering in production
InMemoryUsageLedger is the reference implementation (bounded memory,
deduped by message id). For a real backend, implement record() — and
totals() if you want quota enforcement:
class PostgresLedger:
def record(self, tenant_id: str, record: UsageRecord) -> None:
db.insert_usage(tenant_id, record)
def totals(self, tenant_id: str) -> TenantUsage: # enables quotas
return db.usage_totals(tenant_id)
A raising ledger does not fail checkpoint writes: the error is reported
through on_event (type "ledger_error") and the record is retried on the
next checkpoint. Pass ledger_errors="throw" if you'd rather fail the write.
Metering extracts from both checkpoints and pending writes, so it keeps
working with DeltaChannel (beta) graphs, whose checkpoints carry only a
sentinel instead of the accumulated messages.
Observability
Every denial is a security-relevant event. Wire them to your logger, metrics, or OpenTelemetry:
def on_event(event): # TenancyEvent
# event.type: "denied" | "quota_exceeded" | "ledger_error"
logger.warning("tenancy %s", event)
checkpointer = TenantScopedCheckpointer(inner, on_event=on_event)
store = TenantScopedStore(inner_store, on_event=on_event)
Handler errors are swallowed — observers can never break the data path.
Admin, GDPR, and migration
Everything out-of-band goes through an explicit per-tenant handle:
acme = checkpointer.for_tenant("acme")
acme.list_threads() # every thread id belonging to acme
acme.delete_thread("t1") # one thread
acme.purge() # GDPR erasure: every acme thread
acme.copy_thread("t1", "t2") # tenant-scoped copy
acme.prune(["t1", "t2"]) # tenant-scoped prune
store.for_tenant("acme").purge() # every acme store item
# Adopting langgraph-tenancy on an existing deployment? Migrate pre-tenancy
# (unprefixed) threads under their rightful tenant, history intact:
acme.adopt_thread("legacy-thread-id", delete_source=True)
adopt_thread replays every checkpoint — order, parentage, metadata, and
pending writes — under the tenant-scoped key, so conversations continue
exactly where they left off.
No magic
The entire mechanism is key prefixing plus mandatory-context checks, in a few small files you can audit in ten minutes:
- thread ids become
"{tenant_id}::{thread_id}"before reaching your database; the prefix is stripped from everything returned. - store namespaces
("memories",)become("{tenant_id}", "memories"). - tenant ids are restricted to
[A-Za-z0-9_-]{1,64}— an allowlist, because tenant ids end up inside storage keys and namespace encodings of whatever backend you use, and a blocklist can't anticipate all of them.
It composes with any BaseCheckpointSaver / BaseStore implementation —
Postgres, SQLite, Redis, MongoDB, in-memory — because it never touches
storage itself.
What it is not
- Not authentication. You decide which tenant a request belongs to; this package guarantees that decision is enforced everywhere downstream.
- Not encryption. Combine with
EncryptedSerializerfor at-rest encryption. - Not a replacement for database-level controls in high-assurance setups (RLS, schema-per-tenant) — it's the layer that makes your application unable to leak, whatever the database allows.
Tested
The adversarial test suite — every test attempts a cross-tenant access the
raw LangGraph API allows — runs against InMemorySaver and a real
PostgresSaver in CI. Coverage includes tenant-wide listing, quota
enforcement, GDPR purge, adopt_thread migration, delta-channel metering,
and denial events, all proven on actual SQL storage.
Development
uv venv && uv pip install -e ".[test]"
uv run pytest # postgres tests skip if no server is reachable
# to run the postgres leg locally:
export LG_TENANCY_PG_URI=postgresql://user@localhost:5432/langgraph_tenancy_test
uv run pytest
License
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file langgraph_tenancy-0.2.0.tar.gz.
File metadata
- Download URL: langgraph_tenancy-0.2.0.tar.gz
- Upload date:
- Size: 22.0 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
2db824137dd36f81a994ebc429b754d7b3c9201299bd5760a95fe6749af9865e
|
|
| MD5 |
962eb5ee06b789b75f14909202374467
|
|
| BLAKE2b-256 |
18057b5d6a4a1a9951b230d6f29b959ee2133f9839edabab3be5fb43bdac1832
|
Provenance
The following attestation bundles were made for langgraph_tenancy-0.2.0.tar.gz:
Publisher:
release.yml on ac12644/langgraph-tenancy
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
langgraph_tenancy-0.2.0.tar.gz -
Subject digest:
2db824137dd36f81a994ebc429b754d7b3c9201299bd5760a95fe6749af9865e - Sigstore transparency entry: 2189525462
- Sigstore integration time:
-
Permalink:
ac12644/langgraph-tenancy@014fbfdd3bfc5820a6b65fd0ffbb9289ed8ca976 -
Branch / Tag:
refs/tags/v0.2.0 - Owner: https://github.com/ac12644
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@014fbfdd3bfc5820a6b65fd0ffbb9289ed8ca976 -
Trigger Event:
release
-
Statement type:
File details
Details for the file langgraph_tenancy-0.2.0-py3-none-any.whl.
File metadata
- Download URL: langgraph_tenancy-0.2.0-py3-none-any.whl
- Upload date:
- Size: 20.3 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
a8c55d2ac992ff916170d99688aa1f791910ed9fed229a0175a7e33c1ac5da31
|
|
| MD5 |
9b7eddd9058db434cfa2ee126e6353bf
|
|
| BLAKE2b-256 |
32c119346665fe78e08ca1044aed38982ce74854baf0909bee7273843732f811
|
Provenance
The following attestation bundles were made for langgraph_tenancy-0.2.0-py3-none-any.whl:
Publisher:
release.yml on ac12644/langgraph-tenancy
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
langgraph_tenancy-0.2.0-py3-none-any.whl -
Subject digest:
a8c55d2ac992ff916170d99688aa1f791910ed9fed229a0175a7e33c1ac5da31 - Sigstore transparency entry: 2189525485
- Sigstore integration time:
-
Permalink:
ac12644/langgraph-tenancy@014fbfdd3bfc5820a6b65fd0ffbb9289ed8ca976 -
Branch / Tag:
refs/tags/v0.2.0 - Owner: https://github.com/ac12644
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@014fbfdd3bfc5820a6b65fd0ffbb9289ed8ca976 -
Trigger Event:
release
-
Statement type: