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:
- Automatic filtering: Thread listings only show threads owned by the current principal
- 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-Principalheader
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
TestMode is for development/testing only. In production, use real authentication (OIDC, API keys).
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:
- The caller has a non-empty
subclaim (true for the anonymous principal too — see Unauthenticated Requests and the Anonymous Principal) - 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:
| Layer | Mechanism | Protects Against |
|---|---|---|
| Automatic filtering | Temporal visibility query filter | Enumeration attacks |
| Policy enforcement | Owner check in Rego | Direct 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
- Policy Configuration: Advanced policy patterns
- Authentication: Production auth setup
- Production Deployment: Security hardening