diff --git a/packages/langchain/README.md b/packages/langchain/README.md index 5f34916..e4d7b83 100644 --- a/packages/langchain/README.md +++ b/packages/langchain/README.md @@ -20,6 +20,7 @@ from langchain.agents import create_agent from langchain.tools import tool from keycardai.langchain import ( + Access, KeycardGrantMiddleware, KeycardIdentity, get_access_context, @@ -51,7 +52,7 @@ agent = create_agent( agent.invoke( {"messages": [...]}, - context=KeycardIdentity(subject_token=caller_token), + context=Access.on_behalf_of(caller_token), ) ``` @@ -73,14 +74,14 @@ and `create_deep_agent` (deep agents are built on the same middleware system). ## Access patterns -`KeycardIdentity` carries the identity for a run, and its fields select the -access pattern: +`KeycardIdentity` is the context schema for a run. Use an `Access.*` factory to +select the access pattern: -| Field | Pattern | Meaning | +| Field | Factory | Meaning | |---|---|---| -| `subject_token` | on-behalf-of | Exchange the caller's own token for resource tokens (RFC 8693). | -| `as_self=True` | as itself | Client-credentials grant under the agent's own application identity. No user anywhere. | -| `user_identifier` | impersonation | Substitute-user exchange, authenticated by the agent's credential. Forbidden by default; requires a zone policy. | +| `subject_token` | `Access.on_behalf_of(...)` | Exchange the caller's own token for resource tokens (RFC 8693). | +| `as_self=True` | `Access.as_self()` | Client-credentials grant under the agent's own application identity. No user anywhere. | +| `user_identifier` | `Access.impersonate(...)` | Substitute-user exchange, authenticated by the agent's credential. Forbidden by default; requires a zone policy. | A run with no identity fails with a `missing_identity` error, or pauses with a `sign_in_required` interrupt when `sign_in_url` is set. It never falls back to @@ -93,6 +94,8 @@ call, so every resource access is attributed to agent-for-user in the audit log, and revoking the user's grant cuts the agent off immediately. ```python +from keycardai.langchain import Access + keycard = KeycardGrantMiddleware( zone_url="https://your-zone.keycard.cloud", resources=["https://www.googleapis.com/calendar/v3"], @@ -105,7 +108,7 @@ keycard = KeycardGrantMiddleware( agent.invoke( {"messages": [...]}, - context=KeycardIdentity(subject_token=caller_token), + context=Access.on_behalf_of(caller_token), ) ``` @@ -119,6 +122,8 @@ the zone brokers for the resource, including vaulted secrets, so the worker's environment holds no API keys and revocation lives in one place. ```python +from keycardai.langchain import Access + keycard = KeycardGrantMiddleware( zone_url="https://your-zone.keycard.cloud", resources=["https://api.github.com"], @@ -128,7 +133,7 @@ keycard = KeycardGrantMiddleware( agent.invoke( {"messages": [...]}, - context=KeycardIdentity(as_self=True), + context=Access.as_self(), ) ``` @@ -146,6 +151,8 @@ credential. This is the sharpest tool in the box and is forbidden by default; it requires an explicit impersonation policy in the zone. ```python +from keycardai.langchain import Access + keycard = KeycardGrantMiddleware( zone_url="https://your-zone.keycard.cloud", resources=["https://www.googleapis.com/calendar/v3"], @@ -155,7 +162,7 @@ keycard = KeycardGrantMiddleware( agent.invoke( {"messages": [...]}, - context=KeycardIdentity(user_identifier="user@example.com"), + context=Access.impersonate("user@example.com"), ) ``` @@ -186,9 +193,11 @@ For a deployed agent whose surface does not thread per-run context, set sign-in that happens mid-conversation takes effect on resume without a restart: ```python +from keycardai.langchain import Access + keycard = KeycardGrantMiddleware( ..., - fallback_identity=lambda: KeycardIdentity(subject_token=session_token()), + fallback_identity=lambda: Access.on_behalf_of(session_token()), ) ``` @@ -255,8 +264,10 @@ explicitly. The motivating case is a UI panel served by the same governed tool the agent uses in chat: ```python +from keycardai.langchain import Access + def dashboard_snapshot(session_token: str) -> str: - with keycard.grant(KeycardIdentity(subject_token=session_token)): + with keycard.grant(Access.on_behalf_of(session_token)): return list_requests.invoke({}) ``` @@ -264,7 +275,9 @@ It also serves resources that have no tool at all. Fetching a vaulted LLM key under the agent's own identity, for example: ```python -with keycard.grant(KeycardIdentity(as_self=True), resources=[LLM_KEY]) as access: +from keycardai.langchain import Access + +with keycard.grant(Access.as_self(), resources=[LLM_KEY]) as access: key = access.access(LLM_KEY).access_token ``` diff --git a/packages/langchain/examples/background_agent/README.md b/packages/langchain/examples/background_agent/README.md index f3463eb..55bca6d 100644 --- a/packages/langchain/examples/background_agent/README.md +++ b/packages/langchain/examples/background_agent/README.md @@ -2,9 +2,9 @@ A LangChain agent with no user anywhere: a scheduled PR-review digest that fetches open pull requests from GitHub and summarizes them. It authenticates -as its own Keycard application (`KeycardIdentity(as_self=True)`), and Keycard -delivers whatever credential the zone brokers for the GitHub resource — a -vaulted PAT, a GitHub App token — per tool call. The worker's environment +as its own Keycard application (`Access.as_self()`), and Keycard +delivers whatever credential the zone brokers for the GitHub resource, such as +a vaulted PAT or a GitHub App token, per tool call. The worker's environment holds no GitHub credential, and revoking access happens in one place. ## Keycard setup diff --git a/packages/langchain/examples/background_agent/main.py b/packages/langchain/examples/background_agent/main.py index a76bcd4..c04ac3c 100644 --- a/packages/langchain/examples/background_agent/main.py +++ b/packages/langchain/examples/background_agent/main.py @@ -1,6 +1,6 @@ """A background agent with no user anywhere: a morning PR-review digest. -The agent runs as itself (KeycardIdentity(as_self=True)): resource access is +The agent runs as itself (Access.as_self()): resource access is attributed to the application alone, the GitHub credential lives in the zone (vaulted or brokered), and every fetch is an audit event. Nothing in this process or its environment holds a GitHub credential. @@ -22,6 +22,7 @@ from langchain_core.messages import HumanMessage from keycardai.langchain import ( + Access, KeycardGrantMiddleware, KeycardIdentity, get_access_context, @@ -118,7 +119,7 @@ def main() -> None: result = agent.invoke( {"messages": [HumanMessage("Compile this morning's review digest.")]}, - context=KeycardIdentity(as_self=True), + context=Access.as_self(), ) print(_text_of(result["messages"][-1])) diff --git a/packages/langchain/examples/user_facing_agent/README.md b/packages/langchain/examples/user_facing_agent/README.md index 388014a..10f1047 100644 --- a/packages/langchain/examples/user_facing_agent/README.md +++ b/packages/langchain/examples/user_facing_agent/README.md @@ -8,7 +8,7 @@ off immediately. When the user has not granted calendar access yet, the middleware pauses the run with a LangGraph `authorization_required` interrupt. This CLI prints the -consent link, waits, and resumes the same run — in a chat UI the same payload +consent link, waits, and resumes the same run. In a chat UI the same payload becomes an in-chat sign-in card. ## Keycard setup @@ -35,8 +35,8 @@ uv run main.py "what's on my calendar today?" ## What to look at -- The identity for the run is `KeycardIdentity(subject_token=...)`, passed as - LangChain runtime context — not middleware state, so one deployed agent +- The identity for the run is `Access.on_behalf_of(...)`, passed as + LangChain runtime context, not middleware state, so one deployed agent serves many users. - The interrupt/resume loop at the bottom of `main.py`: consent changes the grant in the zone, not the token in your session, so the resume retries the diff --git a/packages/langchain/examples/user_facing_agent/main.py b/packages/langchain/examples/user_facing_agent/main.py index ff5a231..bfd6e52 100644 --- a/packages/langchain/examples/user_facing_agent/main.py +++ b/packages/langchain/examples/user_facing_agent/main.py @@ -24,6 +24,7 @@ from langgraph.types import Command from keycardai.langchain import ( + Access, KeycardGrantMiddleware, KeycardIdentity, get_access_context, @@ -71,7 +72,7 @@ def list_events(days_ahead: int = 0) -> str: def main() -> None: question = " ".join(sys.argv[1:]) or "What's on my calendar today?" - identity = KeycardIdentity(subject_token=os.environ["KEYCARD_SUBJECT_TOKEN"]) + identity = Access.on_behalf_of(os.environ["KEYCARD_SUBJECT_TOKEN"]) keycard = KeycardGrantMiddleware( zone_url=os.environ["KEYCARD_ZONE_URL"], diff --git a/packages/langchain/src/keycardai/langchain/__init__.py b/packages/langchain/src/keycardai/langchain/__init__.py index f174a1c..a3e5611 100644 --- a/packages/langchain/src/keycardai/langchain/__init__.py +++ b/packages/langchain/src/keycardai/langchain/__init__.py @@ -8,6 +8,7 @@ from langchain.agents import create_agent from keycardai.langchain import ( + Access, KeycardGrantMiddleware, KeycardIdentity, get_access_context, @@ -35,13 +36,14 @@ def call_api(query: str) -> str: agent.invoke( {"messages": [...]}, - context=KeycardIdentity(subject_token=caller_token), + context=Access.on_behalf_of(caller_token), ) Re-export guide: -- Local definitions: ``KeycardGrantMiddleware``, ``KeycardIdentity``, - ``get_access_context``. +- Local definitions: ``Access``, ``KeycardGrantMiddleware``, + ``KeycardIdentity``, ``get_access_context``. ``KeycardIdentity`` is the + context schema, and can also be constructed directly. - Borrowed from ``keycardai-oauth``: ``AccessContext`` (the per-request token container) and ``ResourceAccessError`` (raised only by ``AccessContext.access``), re-exported so callers need one import. @@ -50,6 +52,7 @@ def call_api(query: str) -> str: from keycardai.oauth.server.access_context import AccessContext from keycardai.oauth.server.exceptions import ResourceAccessError +from .access import Access from .middleware import ( KeycardGrantMiddleware, KeycardIdentity, @@ -58,6 +61,7 @@ def call_api(query: str) -> str: __all__ = [ # === Primary API === + "Access", "KeycardGrantMiddleware", "KeycardIdentity", "get_access_context", diff --git a/packages/langchain/src/keycardai/langchain/access.py b/packages/langchain/src/keycardai/langchain/access.py new file mode 100644 index 0000000..91ec0eb --- /dev/null +++ b/packages/langchain/src/keycardai/langchain/access.py @@ -0,0 +1,44 @@ +"""Factories for the identity a run acts under.""" + +from __future__ import annotations + +from .middleware import KeycardIdentity + + +class Access: + """Namespace of factories for the identity a run acts under. + + Each classmethod builds the KeycardIdentity for one access pattern, so a + call site names the pattern instead of setting a field: + + agent.invoke({"messages": [...]}, context=Access.as_self()) + + The agent's context_schema stays KeycardIdentity; these factories only + construct it. + """ + + def __init__(self) -> None: + raise TypeError( + "Access is a namespace of factories, not a type. Call " + "Access.as_self(), Access.on_behalf_of(subject_token), or " + "Access.impersonate(user_identifier)." + ) + + @classmethod + def as_self(cls) -> KeycardIdentity: + """The agent acts as its own application: client credentials, no user.""" + return KeycardIdentity(as_self=True) + + @classmethod + def on_behalf_of(cls, subject_token: str) -> KeycardIdentity: + """The agent acts for the caller, exchanging the caller's token (RFC 8693).""" + if not subject_token or not subject_token.strip(): + raise ValueError("Access.on_behalf_of requires a non-empty subject token") + return KeycardIdentity(subject_token=subject_token) + + @classmethod + def impersonate(cls, user_identifier: str) -> KeycardIdentity: + """The agent acts as a named user, authenticated by its own credential.""" + if not user_identifier or not user_identifier.strip(): + raise ValueError("Access.impersonate requires a non-empty user identifier") + return KeycardIdentity(user_identifier=user_identifier) diff --git a/packages/langchain/src/keycardai/langchain/middleware.py b/packages/langchain/src/keycardai/langchain/middleware.py index fe0d61a..ab9c7f5 100644 --- a/packages/langchain/src/keycardai/langchain/middleware.py +++ b/packages/langchain/src/keycardai/langchain/middleware.py @@ -96,8 +96,8 @@ def get_access_context() -> AccessContext: if access is None: raise RuntimeError( "No Keycard AccessContext for this tool call. Add KeycardGrantMiddleware " - "to the agent's middleware list and invoke the agent with a " - "KeycardIdentity context." + "to the agent's middleware list and invoke the agent with an " + "Access.* identity as context." ) return access @@ -346,9 +346,9 @@ async def _build_access_for( "No Keycard identity for this run. Sign in to continue." if self._sign_in_url else "No Keycard identity on the runtime context. Invoke the " - "agent with context=KeycardIdentity(subject_token=...), " - "KeycardIdentity(user_identifier=...), or " - "KeycardIdentity(as_self=True)." + "agent with context=Access.on_behalf_of(...), " + "Access.impersonate(...), or " + "Access.as_self()." ), "code": "missing_identity", } @@ -497,14 +497,14 @@ def grant( Lets the same governed tools back non-agent surfaces, e.g. seeding a dashboard panel on page load with the tool the agent uses in chat: - with keycard.grant(KeycardIdentity(subject_token=token)): + with keycard.grant(Access.on_behalf_of(token)): rows = list_requests.invoke({}) Also serves resources that have no tool at all, e.g. fetching a vaulted LLM key under the agent's own identity: with keycard.grant( - KeycardIdentity(as_self=True), resources=[LLM_KEY] + Access.as_self(), resources=[LLM_KEY] ) as access: key = access.access(LLM_KEY).access_token diff --git a/packages/langchain/tests/test_access.py b/packages/langchain/tests/test_access.py new file mode 100644 index 0000000..5e29a5d --- /dev/null +++ b/packages/langchain/tests/test_access.py @@ -0,0 +1,77 @@ +"""The Access factories: what they build, what they reject, where they route. + +The end-to-end cases reuse the agent harness from test_middleware, so each +factory is checked against the middleware path it is supposed to drive. +""" + +from __future__ import annotations + +import pytest +from test_middleware import ( + PROMPT, + RESOURCE, + StubExchangeClient, + build_agent, + last_tool_message, +) + +from keycardai.langchain import Access, KeycardIdentity + + +def test_factories_build_the_expected_identities() -> None: + assert Access.as_self() == KeycardIdentity(as_self=True) + assert Access.on_behalf_of("caller-token") == KeycardIdentity( + subject_token="caller-token" + ) + assert Access.impersonate("user@example.com") == KeycardIdentity( + user_identifier="user@example.com" + ) + + +def test_access_namespace_cannot_be_instantiated() -> None: + with pytest.raises(TypeError): + Access() + + +@pytest.mark.parametrize("value", ["", " ", "\t\n"]) +def test_on_behalf_of_rejects_empty_subject_tokens(value: str) -> None: + with pytest.raises(ValueError, match="non-empty subject token"): + Access.on_behalf_of(value) + + +@pytest.mark.parametrize("value", ["", " ", "\t\n"]) +def test_impersonate_rejects_empty_user_identifiers(value: str) -> None: + with pytest.raises(ValueError, match="non-empty user identifier"): + Access.impersonate(value) + + +def test_as_self_uses_client_credentials_without_exchange() -> None: + stub = StubExchangeClient() + result = build_agent(stub).invoke(PROMPT, context=Access.as_self()) + + assert f"TOKEN: self-token-for-{RESOURCE}" in last_tool_message(result).content + assert not stub.exchange_calls + assert stub.self_calls == [{"resource": RESOURCE}] + + +def test_on_behalf_of_exchanges_the_subject_token() -> None: + stub = StubExchangeClient() + result = build_agent(stub).invoke( + PROMPT, context=Access.on_behalf_of("caller-token") + ) + + assert f"TOKEN: obo-token-for-{RESOURCE}" in last_tool_message(result).content + assert stub.exchange_calls[0].subject_token == "caller-token" + + +def test_impersonate_uses_substitute_user_without_exchange() -> None: + stub = StubExchangeClient() + result = build_agent(stub).invoke( + PROMPT, context=Access.impersonate("user@example.com") + ) + + assert "TOKEN: impersonated-user@example.com" in last_tool_message(result).content + assert not stub.exchange_calls + assert stub.impersonate_calls == [ + {"user": "user@example.com", "resource": RESOURCE} + ]