Authorization scopes

In shortRead caller identity and enforce scopes inside graph nodes and tools using get_authz, has_scope, and isolation_scope.

  • 7 min read
  • 10 sections
  • Updated
  • v0.10.0
  • Markdown

When your 10xGraph application runs over the API server, the caller’s identity and scopes are automatically added to the request. Inside your nodes and tools, you can read this information to enforce fine-grained permissions: restrict certain operations to authorized users, log who performed actions, or apply policies like “only the thread owner can read this.”

This guide covers the three authorization functions, get_authz, has_scope, and isolation_scope, and how to use them in your agent code.

How authorization reaches your code

The API server authenticates every request and builds an authorization context from the verified identity and the configured authorization backend. This context is stamped into config["user"]["authz"] before your graph runs. Inside any node, tool, or hook, you can access this information through the config:

Python
from tenxgraph.core.authz import get_authz, has_scope
from tenxgraph.utils import tool

@tool
def sensitive_operation(*, config):
    authz = get_authz(config)
    if authz is None:
        # No authz context, either direct SDK call with no authz,
        # or auth is not configured on the server.
        print("No authorization context")
        return "Allowed (no policy)"
    
    user_id = authz.get("user_id")
    scopes = authz.get("scopes", [])
    print(f"User {user_id} has scopes: {scopes}")
    return f"Operation performed by {user_id}"

When you run over the API server with auth enabled, get_authz() always returns a dict with three keys:

  • user_id: The authenticated caller’s identity.
  • scope: The data isolation policy ("owner" or "none").
  • scopes: A list of resource:action permissions the caller holds.

When no auth is configured, get_authz() returns None, and all scopes are implicitly allowed. If auth is on but the identity carries no scopes and the backend defines none, the block is stamped with an empty scopes list and has_scope() returns False for every scope.

Check if a caller has a specific scope

Use has_scope() to gate operations behind permissions:

Python
from tenxgraph.core.authz import has_scope

@tool
def create_memory(text: str, *, config):
    """Store long-term memory, only for users with store:write scope."""
    if not has_scope(config, "store:write"):
        raise PermissionError("You do not have permission to write to memory")
    
    # Proceed with the memory operation
    return f"Memory created: {text}"

The scope string is always "resource:action". The full list of scopes is:

  • Graph execution: graph:invoke, graph:stream, graph:stop, graph:fix, graph:setup, graph:read
  • Thread state (checkpointer): checkpointer:read, checkpointer:write, checkpointer:delete
  • Long-term memory (store): store:read, store:write, store:delete
  • Media and files: files:read, files:upload
  • Configuration: config:read

has_scope() is permissive by default: if no authorization context is present (auth not configured, or a direct SDK call without an authz block), it returns True. This ensures backward compatibility, existing graphs work unchanged.

Read caller identity inside tools and nodes

The user_id from the authz block tells you who made the request. Use it to log actions, associate resources with owners, or enforce object-level access control:

Python
from tenxgraph.core.authz import get_authz
from tenxgraph.utils import tool

@tool
def query_user_threads(*, config):
    """Retrieve threads belonging to the caller."""
    authz = get_authz(config)
    if authz is None:
        # No identity available; cannot scope by user.
        return []
    
    user_id = authz.get("user_id")
    # Query the checkpointer or thread store filtered to this user.
    # (The checkpointer respects isolation_scope too; see below.)
    return find_threads_for_user(user_id)

Combining user_id with a checkpointer that supports owner-only access (like PgCheckpointer, which enforces user isolation by default) ensures that data isolation is enforced at the storage layer too.

Understand isolation scope

The isolation_scope returned by isolation_scope() tells you (and the storage layer) which data-isolation policy is in effect:

Python
from tenxgraph.core.authz import isolation_scope, SCOPE_OWNER, SCOPE_NONE

def authorize_thread_access(thread_id: str, user_id: str, config):
    """Apply custom per-thread access control based on the isolation policy."""
    policy = isolation_scope(config)
    
    if policy == SCOPE_OWNER:
        # Owner-only: validate that thread_id is owned by user_id.
        owner = get_thread_owner(thread_id)  # Your storage backend
        if owner != user_id:
            raise PermissionError(f"Thread {thread_id} is owned by {owner}, not {user_id}")
    elif policy == SCOPE_NONE:
        # No isolation: any authenticated user can access.
        pass
    else:
        # No policy set; each layer applies its own default.
        pass
  • SCOPE_OWNER ("owner"): Data is scoped to the caller’s user_id. The checkpointer filters rows; your code should too.
  • SCOPE_NONE ("none"): No scoping. All authenticated users see all data.
  • None: No policy is set. Storage backends (like PgCheckpointer) fall back to their own enforce_user_isolation setting.

In practice, the checkpointer and store already respect the isolation policy, so you rarely need to call isolation_scope() directly. Use it when your code implements custom data filtering or when you want to log what policy is active.

Example: Multi-tenant agent with scope checks

Install with pip install "10xgraph[google-genai]". Here is a complete example of a graph that uses authorization scopes to enforce multi-tenant data isolation and fine-grained permissions:

Python
from tenxgraph.core.authz import get_authz, has_scope
from tenxgraph.prebuilt.agent import ReactAgent
from tenxgraph.utils import tool

# Stand-in storage layer for the example; use your own database.
NOTES: dict[str, list[str]] = {}


def fetch_user_notes(user_id):
    return NOTES.get(user_id, [])


def save_note(user_id, text):
    NOTES.setdefault(user_id, []).append(text)
    return len(NOTES[user_id])

@tool
def list_notes(*, config) -> str:
    """List notes belonging to the current user."""
    authz = get_authz(config)
    if authz is None:
        return "Error: No authorization context"
    
    user_id = authz.get("user_id")
    # Your storage layer (e.g., memory store) returns notes for this user.
    notes = fetch_user_notes(user_id)
    return f"Found {len(notes)} notes for {user_id}"

@tool
def create_note(text: str, *, config) -> str:
    """Create a note, restricted to users with store:write scope."""
    if not has_scope(config, "store:write"):
        raise PermissionError("Missing scope: store:write")
    
    authz = get_authz(config)
    user_id = authz.get("user_id") if authz else "anonymous"
    
    # Store the note, associated with the user.
    note_id = save_note(user_id, text)
    return f"Note created (id={note_id}) for {user_id}"

@tool
def export_data(format: str, *, config) -> str:
    """Export all user data, admin-only."""
    if not has_scope(config, "config:read"):
        raise PermissionError("Missing scope: config:read")
    
    authz = get_authz(config)
    user_id = authz.get("user_id")
    return f"Exported {format} for {user_id}"

# ReactAgent wires the agent node, tool node and routing for you
agent = ReactAgent(
    model="gemini/gemini-2.5-flash",
    tools=[list_notes, create_note, export_data],
)
compiled = agent.compile()

When you invoke this over the API server:

  1. A user authenticates (JWT or custom auth).
  2. The authorization backend (OwnershipAuthorizationBackend or custom) checks scopes.
  3. RequirePermission builds user["authz"] with the user’s ID and granted scopes.
  4. Your graph runs; each tool calls get_authz(), has_scope(), or isolation_scope().
  5. If a tool tries to perform an unauthorized action, it raises an error.
  6. If the caller’s scopes don’t include store:write, create_note() fails immediately with a PermissionError.

Direct SDK calls with authorization

When you call CompiledGraph.invoke() directly in Python (not over the API server), you can build and pass an authz block yourself:

Python
from tenxgraph.core.authz import build_authz
from tenxgraph.core.state import Message

authz_block = build_authz(
    user_id="alice",
    scope="owner",
    scopes=["graph:invoke", "checkpointer:read", "store:write"]
)

result = compiled.invoke(
    {"messages": [Message.text_message("List my notes")]},
    config={"authz": authz_block, "thread_id": "session-1"}
)

Without an authz block, get_authz() returns None, and all functions default to permissive (all scopes allowed, no isolation). This keeps your graphs backward compatible.

How the API server fills the authz block

When a request reaches the API server, the RequirePermission dependency (in the route’s Depends()) performs this flow:

  1. Authenticate: Extract and verify the bearer token (JWT or custom BaseAuth). Decode claims to get user_id, roles, scopes, etc.
  2. Check scopes: Call the authorization backend’s scopes_for(user) (for example RoleBasedAuthorizationBackend maps roles to scopes), falling back to user["scopes"]. If scopes are declared, verify that the required scope (e.g., "graph:invoke" for a POST to /v1/graph/invoke) is granted.
  3. Check object access: Call authorize(user, resource, action, resource_id) to enforce object-level rules (e.g., ownership checks).
  4. Build and stamp authz: Call build_authz(user_id, scope=isolation_policy, scopes=granted_scopes) and place it in user["authz"]. This overwrites anything the client sent, it is non-hijackable.
  5. Place in config: Every service (graph execution, checkpointer, store) copies user into config["user"], so the authz block reaches your code.

This flow is defined in tenxgraph_api/src/app/core/auth/permissions.py. For details on configuring backends, see /docs/server/auth.

Common errors and fixes

PermissionError: “Missing scope: store:write”

The caller doesn’t have the required scope. This happens when:

  • The user’s role is not mapped to that scope in your authorization backend.
  • The identity’s scopes (for example from the JWT claims) don’t include the required scope.
  • The identity declares no scopes, so has_scope() sees an empty list.

Fix: Check your authorization setting in 10xgraph.json and your role-scope mappings.

get_authz() returns None inside a tool

This is not an error, it means either:

  • Auth is not configured on the API server ("auth": null in 10xgraph.json).
  • You’re calling the graph directly in Python without passing an authz block.

In both cases, has_scope() and isolation_scope() default to permissive, and your code should handle the None case gracefully.

“Thread is owned by user2, not user1”

The caller tried to access a thread they don’t own, but the ownership check passed the initial route guard. This means:

  • The ownership backend is configured but your code is checking ownership a second time.
  • Or, you’re using a custom authorization backend that does not enforce ownership.

Fix: Ensure your authorization backend (OwnershipAuthorizationBackend or a custom one) checks thread ownership. For a custom check, call await checkpointer.aget_thread_owner(thread_id) and compare.

  • /docs/server/auth: Configure authentication and authorization on the API server.
  • /docs/guides/protect-against-prompt-injection: Additional safety patterns for input validation.
  • /docs/guides/use-dependency-injection: Access config and other dependencies in nodes and tools.
  • /docs/concepts/security-and-validators: Design patterns for secure agent systems.

Frequently asked questions

What is the difference between identity and scopes?
Identity is the caller (user_id), set by the auth system. Scopes are permissions - each scope is a resource:action pair like graph:invoke or store:read. The API server stamps both into config user authz so your nodes and tools can enforce them.
Can I use these functions in direct SDK calls?
Yes. In direct calls to CompiledGraph.invoke(), you build the authz block yourself and pass it in config authz. If you do not pass one, all functions return permissive defaults (all scopes allowed, no isolation).
Who fills the authz block when I use the API server?
The API server's auth system (RequirePermission) fills user authz server-side after authenticating and authorizing the request. This happens before your graph runs and is non-hijackable - clients cannot override it.
Last updated for v0.10.0Edit this page on GitHubReport an issue