Skip to main content

Multi-Tenancy

AgentVisor™ provides built-in multi-tenancy, ensuring users can only access their own resources. This guide demonstrates the two mechanisms that enable tenant isolation:

  1. Automatic filtering: Thread listings only show threads owned by the current principal
  2. Policy enforcement: Operations on resources require principal.sub == resource.owner

What You'll Learn

  • How thread ownership works
  • Automatic isolation in thread listings
  • Policy-based access control for operations
  • Testing multi-tenancy with the X-Test-Principal header

Prerequisites

Complete the Tutorial guides first. You should have:

  • AgentVisor CLI installed
  • Docker installed and running (used to start Temporal + Ollama via the scaffolded project's compose.yml, as in Your First Agent)

How Multi-Tenancy Works

Thread Ownership

When a principal creates a thread, their identity (principal.sub) is stored as the thread owner:

Principal: alice@example.com


POST /threads ───────► Thread { owner: "alice@example.com" }

This ownership propagates to all child resources (runs, state) automatically.

Two-Layer Isolation

Layer 1: Automatic Filtering

When listing threads (POST /threads/search), AgentVisor automatically adds a per-principal filter to the underlying Temporal visibility query, so results only include threads owned by the current principal — users never even see other tenants' thread IDs. This filter is skipped for the anonymous principal (there is no tenant to scope to), so an anonymous caller sees every thread.

Layer 2: Policy Enforcement

For direct operations (get, delete, update), policies enforce ownership:

allow if {
input.principal.sub != ""
input.resource.owner == input.principal.sub
}

Even if a user somehow obtains another tenant's thread ID, policy enforcement blocks the operation. The listing operation above shares the same resource group as these direct operations, but its resource carries no owner field at all — the full policy shown in the walkthrough below also grants that case, relying on Layer 1 rather than an owner check to scope the results.

Walkthrough: Demonstrating Isolation

Let's prove multi-tenancy works by simulating two users: Alice and Bob.

Setup

We'll use the chatbot example with a policy that enforces owner-based access:

agentvisor template create langgraph/chatbot-agent
cd chatbot-agent
docker compose up -d

First, create a policy that demonstrates multi-tenancy. Create policies/multi-tenant.yml:

apiVersion: iamlite.manetu.io/v1beta1
kind: PolicyDomain
metadata:
name: multi-tenant-demo
spec:
policies:
# Operation phase - allow authenticated users to continue to the other phases
- mrn: &policy-operation "mrn:iam:policy:operation"
name: operation
rego: |
package authz
import rego.v1
default allow := -1
allow := 0 if input.principal.sub != ""

# Identity phase - any principal holding the "user" role passes
- mrn: &policy-authenticated "mrn:iam:policy:authenticated"
name: authenticated
rego: |
package authz
import rego.v1
default allow := false
allow if input.principal.sub != ""

# Owner-access policy - users can only access their own resources.
# Listing operations carry no "owner" on the resource descriptor
# (Layer 1's automatic filtering already scopes the result set), so
# this also grants any authenticated caller when no owner is present.
- mrn: &policy-owner "mrn:iam:policy:owner-access"
name: owner-access
rego: |
package authz
import rego.v1
default allow := false

allow if {
not input.resource.owner
input.principal.sub != ""
}

allow if {
input.resource.owner == input.principal.sub
}

# HTTP policy for Ollama
- mrn: &policy-http "mrn:iam:policy:http-ollama"
name: http-ollama
rego: |
package authz
import rego.v1
default allow := false

http_target := substring(input.resource.id, count("mrn:agentvisor:http:"), -1)

allow if {
input.principal.sub != ""
regex.match("^(ollama|localhost:11434|127\\.0\\.0\\.1:11434).*", http_target)
}

# Deny everything not explicitly categorized
- mrn: &policy-deny "mrn:iam:policy:deny-all"
name: deny-all
rego: |
package authz
default allow = false

# Identity phase - roles a principal can hold, and the groups that grant
# them. Test principals can't set "mroles" directly (the test
# authenticator strips it - see pkg/authn/test.go, and the note below),
# so this walkthrough grants the "user" role via "mgroups" instead. A
# real OIDC deployment would configure the identity provider to emit
# "mroles" directly - see the OIDC Setup guide.
roles:
- mrn: &role-user "mrn:iam:role:user"
name: user
policy: *policy-authenticated

groups:
- mrn: "mrn:iam:group:users"
name: users
roles:
- *role-user

resource-groups:
# Threads - owner-based access; also covers listing (see policy-owner above)
- mrn: "mrn:agentvisor:resourcegroup:threads"
name: threads
policy: *policy-owner

# Runs - owner-based access (inherited from thread)
- mrn: "mrn:agentvisor:resourcegroup:runs"
name: runs
policy: *policy-owner

# State - owner-based access
- mrn: "mrn:agentvisor:resourcegroup:state"
name: state
policy: *policy-owner

# Agents - any authenticated caller may list/read agent metadata
- mrn: "mrn:agentvisor:resourcegroup:agents"
name: agents
policy: *policy-authenticated

# Store - owner-based access
- mrn: "mrn:agentvisor:resourcegroup:store"
name: store
policy: *policy-owner

# HTTP resources
- mrn: "mrn:agentvisor:resourcegroup:http"
name: http
policy: *policy-http

# Default deny - anything not explicitly categorized above
- mrn: "mrn:agentvisor:resourcegroup:default"
name: default
default: true
policy: *policy-deny

resources:
- name: http-endpoints
selector: ["mrn:agentvisor:http:.*"]
group: "mrn:agentvisor:resourcegroup:http"

operations:
- name: all-ops
selector: ["agentvisor:.*"]
policy: *policy-operation

Start the agent with this policy and TestMode enabled (allows principal injection via headers). TestMode alone isn't enough — X-Test-Principal is only parsed when authentication is also enabled, so both env vars are required or the entire walkthrough below will silently run as the anonymous principal:

AGENTVISOR_API_AUTH_TEST_MODE=true AGENTVISOR_API_AUTH_ENABLED=true agentvisor serve . --policy ./policies/multi-tenant.yml
warning

TestMode is for development/testing only. In production, use real authentication (OIDC, API keys).

Why mgroups instead of mroles?

The test authenticator (pkg/authn/test.go) strips any mroles claim from X-Test-Principal before it reaches policy evaluation — otherwise anyone with TestMode enabled could mint an admin-equivalent principal just by naming the role in the header. mgroups isn't stripped, so this walkthrough uses it to grant the user role instead. A real OIDC token would carry mroles directly (see OIDC Setup).

Step 1: Alice Creates Threads

In a new terminal, create two threads as Alice using the X-Test-Principal header:

# Create Alice's first thread
ALICE_THREAD_1=$(curl -sX POST http://localhost:8090/threads \
-H 'X-Test-Principal: {"sub": "alice@example.com", "mgroups": ["mrn:iam:group:users"]}' \
| jq -r '.thread_id')
echo "Alice's Thread 1: $ALICE_THREAD_1"

# Create Alice's second thread
ALICE_THREAD_2=$(curl -sX POST http://localhost:8090/threads \
-H 'X-Test-Principal: {"sub": "alice@example.com", "mgroups": ["mrn:iam:group:users"]}' \
| jq -r '.thread_id')
echo "Alice's Thread 2: $ALICE_THREAD_2"

Step 2: Bob Creates Threads

Now create two threads as Bob:

# Create Bob's first thread
BOB_THREAD_1=$(curl -sX POST http://localhost:8090/threads \
-H 'X-Test-Principal: {"sub": "bob@example.com", "mgroups": ["mrn:iam:group:users"]}' \
| jq -r '.thread_id')
echo "Bob's Thread 1: $BOB_THREAD_1"

# Create Bob's second thread
BOB_THREAD_2=$(curl -sX POST http://localhost:8090/threads \
-H 'X-Test-Principal: {"sub": "bob@example.com", "mgroups": ["mrn:iam:group:users"]}' \
| jq -r '.thread_id')
echo "Bob's Thread 2: $BOB_THREAD_2"

Step 3: Verify Automatic Filtering

List threads as Alice - she should only see her two threads:

echo "=== Alice's view ==="
curl -sX POST http://localhost:8090/threads/search \
-H 'X-Test-Principal: {"sub": "alice@example.com", "mgroups": ["mrn:iam:group:users"]}' \
-H "Content-Type: application/json" \
-d '{}' \
| jq '[.[] | .thread_id]'

Expected output: Alice sees only her thread IDs:

["<alice-thread-1-id>", "<alice-thread-2-id>"]

List threads as Bob - he should only see his two threads:

echo "=== Bob's view ==="
curl -sX POST http://localhost:8090/threads/search \
-H 'X-Test-Principal: {"sub": "bob@example.com", "mgroups": ["mrn:iam:group:users"]}' \
-H "Content-Type: application/json" \
-d '{}' \
| jq '[.[] | .thread_id]'

Expected output: Bob sees only his thread IDs:

["<bob-thread-1-id>", "<bob-thread-2-id>"]

Step 4: Verify Policy Enforcement

Now let's test what happens when Bob tries to access Alice's thread directly.

Bob tries to get Alice's thread:

echo "=== Bob trying to GET Alice's thread ==="
curl -sX GET "http://localhost:8090/threads/$ALICE_THREAD_1" \
-H 'X-Test-Principal: {"sub": "bob@example.com", "mgroups": ["mrn:iam:group:users"]}' \
| jq

Expected output: 403 Forbidden

{
"error": "access denied: denied by policy",
"code": "forbidden"
}

Bob tries to delete Alice's thread:

echo "=== Bob trying to DELETE Alice's thread ==="
curl -sX DELETE "http://localhost:8090/threads/$ALICE_THREAD_1" \
-H 'X-Test-Principal: {"sub": "bob@example.com", "mgroups": ["mrn:iam:group:users"]}' \
| jq

Expected output: 403 Forbidden

{
"error": "access denied: denied by policy",
"code": "forbidden"
}

Alice can access her own thread:

echo "=== Alice accessing her own thread ==="
curl -sX GET "http://localhost:8090/threads/$ALICE_THREAD_1" \
-H 'X-Test-Principal: {"sub": "alice@example.com", "mgroups": ["mrn:iam:group:users"]}' \
| jq

Expected output: 200 OK with thread details.

Step 5: Verify Run Isolation

Thread ownership extends to runs. Bob can't create runs on Alice's threads:

echo "=== Bob trying to create a run on Alice's thread ==="
curl -sX POST "http://localhost:8090/threads/$ALICE_THREAD_1/runs?wait=5s" \
-H 'X-Test-Principal: {"sub": "bob@example.com", "mgroups": ["mrn:iam:group:users"]}' \
-H "Content-Type: application/json" \
-d '{"input": {"messages": [{"role": "user", "content": "Hello"}]}}' \
| jq

Expected output: 403 Forbidden

Understanding the Policy

The key policy rule that enables multi-tenancy is:

allow if {
input.principal.sub != ""
input.resource.owner == input.principal.sub
}

This checks:

  1. The caller has a non-empty sub claim (true for the anonymous principal too — see Unauthenticated Requests and the Anonymous Principal)
  2. The resource owner matches the principal's identity

PORC Structure

For a thread operation, the PORC (Principal, Operation, Resource, Context) looks like:

{
"principal": {
"sub": "bob@example.com",
"mgroups": ["mrn:iam:group:users"]
},
"operation": "agentvisor:thread:read",
"resource": {
"id": "mrn:agentvisor:thread:abc123",
"owner": "alice@example.com",
"group": "mrn:agentvisor:resourcegroup:threads"
},
"context": {}
}

Since bob@example.com != alice@example.com, the policy denies access.

Production Considerations

Real Authentication

The X-Test-Principal header requires both AGENTVISOR_API_AUTH_TEST_MODE=true and AGENTVISOR_API_AUTH_ENABLED=true, and is for testing only. In production, use OIDC tokens (JWT tokens from your identity provider), with mroles mapped by your identity provider instead of the mgroups trick this guide uses for testing.

See Authentication for configuration.

Admin Access

You may want admins to access all resources. Add an admin role check (needs import rego.v1 in the enclosing policy for the bare in below to parse):

package authz
import rego.v1

# Admin can access any resource
allow if {
"mrn:agentvisor:role:admin" in input.principal.mroles
}

This only works for callers with a real mroles claim — an OIDC token whose identity provider is configured to emit it (see OIDC Setup). X-Test-Principal cannot exercise this rule: the test authenticator strips mroles from the header (see the note in Setup above).

Shared Resources

For resources that should be shared (e.g., public agents), use a different policy:

package authz

# Public read access
default allow = true

This grants access unconditionally — with no principal check at all, it also grants the anonymous principal. Only use it for resources that genuinely have no confidentiality requirement, and never as the default policy for the threads/runs/state/store resource groups.

Summary

AgentVisor provides defense-in-depth for multi-tenancy:

LayerMechanismProtects Against
Automatic filteringTemporal visibility query filterEnumeration attacks
Policy enforcementOwner check in RegoDirect access attempts

Both layers work together: filtering prevents users from discovering other tenants' resources, while policy enforcement blocks access even if resource IDs are leaked.

Next Steps