Permission Model: Deep Dive
#permissions #rbac #tool-access-control #authorization
Overview
The permission model controls which tools an agent can execute. It's a multi-layer intersection model where effective_tools = user_tools ∩ agent_tools ∩ group_ceiling ∩ server_ceiling (with special handling for super_admin and agent wildcards).
Key Design:
- Intersection-based — Each layer restricts, never grants
- Empty-means-unrestricted — Ceiling layers use this convention; agent layer uses opt-in
- Computed once at token mint — Baked into JWT, no runtime computation
- No wildcard expansion at runtime — Wildcards are metadata, not permissions
The Four Permission Layers
Layer 1: Agent Tools (Opt-In)
Location: agents.allowed_tools (JSON array in DB)
Semantics: Explicit opt-in. Agent must have tool in list.
Values:
[](empty) → Deny all tools (agent has no grants)["web_search", "calculator"]→ Allow exactly these["*"]→ Defer to user ceiling (wildcard, special handling)
Special Handling of Wildcard:
// In ComputeEffectiveTools
switch {
case len(agentTools) == 0:
// Empty agent tools = no access
return []string{}
case len(agentTools) == 1 && agentTools[0] == "*":
// ["*"] = agent has no restrictions, user ceiling is sole constraint
// Treated as "unrestricted" at agent layer
effectiveAgent = nil // Pass nil to intersection
default:
// Normal list
effectiveAgent = agentTools
}
When effectiveAgent = nil, the intersection function treats agent layer as unrestricted:
func IntersectToolLists(a, b []string) []string {
aEmpty := len(a) == 0
bEmpty := len(b) == 0
switch {
case aEmpty && bEmpty:
return []string{}
case aEmpty:
return dedup(b) // a empty, b non-empty → return b
case bEmpty:
return dedup(a) // b empty, a non-empty → return a
// ...
}
}
Semantics Summary:
[]at agent layer → Output is always[](deny), regardless of user ceiling["*"]at agent layer → Output is user ceiling (agent imposes no restriction)["tool1", "tool2"]at agent layer → Output is intersection with user ceiling
Layer 2: User Tools
Location: users.allowed_tools (JSON array in DB)
Semantics: Empty-means-unrestricted. If empty, user has no restriction at user layer.
Values:
[](empty) → No restriction at user layer (pass-through)["web_search", "calculator"]→ Only these tools permitted- Never uses wildcard (it's not special here)
Example:
- User has
["web_search", "calculator"] - Agent has
["calculator", "sql_query"] - User ∩ Agent =
["calculator"](only tool both permit)
Layer 3: Group Ceiling
Location: groups.ceiling (JSON array)
Semantics: Ceiling layer. Empty-means-unrestricted. Restricts from above.
Values:
[](empty) → No group ceiling restriction["web_search", "calculator", "sql_query"]→ Max these tools
Use Case: Admin assigns users to groups, group has ceiling.
-- Schema
CREATE TABLE groups (
id TEXT PRIMARY KEY,
name TEXT UNIQUE NOT NULL,
ceiling TEXT NOT NULL DEFAULT '[]' -- JSON array
);
CREATE TABLE group_members (
user_id TEXT NOT NULL,
group_id TEXT NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id),
FOREIGN KEY (group_id) REFERENCES groups(id)
);
When loading user's group ceiling:
// Load all groups user belongs to
var groupCeilings [][]string
for _, group := range userGroups {
ceilings := json.Unmarshal(group.Ceiling)
groupCeilings = append(groupCeilings, ceilings)
}
// Intersect all group ceilings (most restrictive)
effectiveGroupCeiling := groupCeilings[0]
for _, ceiling := range groupCeilings[1:] {
effectiveGroupCeiling = IntersectToolLists(effectiveGroupCeiling, ceiling)
}
Layer 4: Server Ceiling
Location: Global config or hardcoded in code
Semantics: Ceiling layer. Empty-means-unrestricted. Ultimate hard ceiling.
Values:
[](empty) → No server-wide restriction (all tools permit-able)["web_search", "calculator", ...]→ Server-wide max tools
Example: Admin wants to disable sql_query tool globally.
-- Could be stored in a config table
CREATE TABLE config (
key TEXT PRIMARY KEY,
value TEXT
);
INSERT INTO config (key, value) VALUES
('server.tool_ceiling', '["web_search", "calculator"]');
Or hardcoded at startup:
// In main.go or config loading
var serverCeiling []string
if cfg.Security.ToolCeiling != nil {
serverCeiling = cfg.Security.ToolCeiling
} else {
serverCeiling = []string{} // Unrestricted
}
The Computation Algorithm
ComputeEffectiveTools Function
func ComputeEffectiveTools(
agentTools []string, // From agent.allowed_tools
userTools []string, // From user.allowed_tools
groupCeiling []string, // From user's groups (intersected)
serverCeiling []string, // From global config
role string, // From user.role
) []string {
// Step 1: Super-admin bypass (short circuit)
if role == "super_admin" {
// Super-admin gets everything in server ceiling (no user/agent restrictions)
out := make([]string, len(serverCeiling))
copy(out, serverCeiling)
return out
}
// Step 2: Agent opt-in gate
var effectiveAgent []string
switch {
case len(agentTools) == 0:
// Agent has no tools → deny all
return []string{}
case len(agentTools) == 1 && agentTools[0] == "*":
// Agent defers to user ceiling → nil (unrestricted at agent layer)
effectiveAgent = nil
default:
// Normal explicit list
effectiveAgent = agentTools
}
// Step 3: User intersection
// agentTools ∩ userTools
afterUser := IntersectToolLists(effectiveAgent, userTools)
// Step 4: Group ceiling
// afterUser ∩ groupCeiling
afterGroup := IntersectToolLists(afterUser, groupCeiling)
// Step 5: Server ceiling
// afterGroup ∩ serverCeiling
return IntersectToolLists(afterGroup, serverCeiling)
}
IntersectToolLists (Set Intersection)
func IntersectToolLists(a, b []string) []string {
aEmpty := len(a) == 0
bEmpty := len(b) == 0
switch {
case aEmpty && bEmpty:
// Both empty → empty
return []string{}
case aEmpty:
// a empty, b non-empty → b (empty means unrestricted)
return dedup(b)
case bEmpty:
// b empty, a non-empty → a (empty means unrestricted)
return dedup(a)
default:
// Both non-empty → true set intersection
setB := make(map[string]struct{}, len(b))
for _, t := range b {
setB[t] = struct{}{}
}
seen := make(map[string]struct{}, len(a))
out := make([]string, 0, len(a))
for _, t := range a {
if _, inB := setB[t]; inB {
if _, already := seen[t]; !already {
seen[t] = struct{}{}
out = append(out, t)
}
}
}
return out
}
}
Key Insight: Empty list = "no restriction at this layer". Two non-empty lists = true intersection.
Complete Example: Step-by-Step Computation
Setup
Agent "assistant":
allowed_tools: ["web_search", "calculator", "sql_query"]
User "alice":
allowed_tools: ["web_search", "calculator"]
role: "user"
groups: ["data_team"]
Group "data_team":
ceiling: ["web_search", "calculator", "database"]
Server:
ceiling: ["web_search", "calculator", "sql_query", "database"]
Computation
Input:
agentTools = ["web_search", "calculator", "sql_query"]
userTools = ["web_search", "calculator"]
groupCeiling = ["web_search", "calculator", "database"]
serverCeiling = ["web_search", "calculator", "sql_query", "database"]
role = "user"
Step 1: Super-admin check
role == "user" → not super-admin, continue
Step 2: Agent opt-in gate
agentTools = ["web_search", "calculator", "sql_query"]
len(agentTools) != 0 and first != "*"
→ effectiveAgent = ["web_search", "calculator", "sql_query"]
Step 3: User intersection
IntersectToolLists(
["web_search", "calculator", "sql_query"], // effectiveAgent
["web_search", "calculator"] // userTools
)
→ ["web_search", "calculator"] (only tools in both lists)
Step 4: Group ceiling
IntersectToolLists(
["web_search", "calculator"], // afterUser
["web_search", "calculator", "database"] // groupCeiling
)
→ ["web_search", "calculator"] (no change, all tools already in group ceiling)
Step 5: Server ceiling
IntersectToolLists(
["web_search", "calculator"], // afterGroup
["web_search", "calculator", "sql_query", "database"] // serverCeiling
)
→ ["web_search", "calculator"]
Final Result: ["web_search", "calculator"]
Alice can use "web_search" and "calculator" with the "assistant" agent,
but NOT "sql_query" (blocked by user ceiling).
Special Cases
Case 1: Agent Uses Wildcard
Agent "any_tools":
allowed_tools: ["*"]
User "bob":
allowed_tools: ["web_search"]
role: "user"
Step 2: Agent opt-in gate
agentTools[0] == "*"
→ effectiveAgent = nil (unrestricted at agent layer)
Step 3: User intersection
IntersectToolLists(nil, ["web_search"])
→ nil is empty, ["web_search"] non-empty
→ Return ["web_search"]
Final: ["web_search"]
(Agent defers to user ceiling, so user ceiling is sole constraint)
Case 2: Super-Admin Bypass
User "root":
role: "super_admin"
allowed_tools: [] (doesn't matter)
Query.ComputeEffectiveTools(..., role="super_admin")
→ Short-circuit at Step 1
→ Return full serverCeiling
→ Super-admin gets all tools
No intersection occurs (super-admin bypasses all layers).
Case 3: Agent Has No Tools
Agent "restricted":
allowed_tools: []
Step 2: Agent opt-in gate
len(agentTools) == 0
→ return [] immediately
Final: []
(Deny all, agent has no access regardless of user ceiling)
Case 4: Empty User Ceiling
User "unrestricted":
allowed_tools: [] (empty)
Agent "web":
allowed_tools: ["web_search", "calculator"]
Step 2: effectiveAgent = ["web_search", "calculator"]
Step 3: IntersectToolLists(["web_search", "calculator"], [])
→ [] is empty, a non-empty
→ Return ["web_search", "calculator"]
Final: ["web_search", "calculator"]
(Empty user ceiling means no restriction at user layer)
Token Minting: Where Computation Happens
When a user requests an agent token via POST /v1/agent-token:
// Handler: handleAgentToken
func handleAgentToken(w http.ResponseWriter, r *http.Request) {
claims := r.Context().Value(ContextKeyAccessClaims).(*AccessClaims)
agentName := r.FormValue("agent_name")
// Load user from DB
user, _ := store.GetUser(ctx, claims.UserID)
// Load agent from DB
agent, _ := store.GetAgent(ctx, agentName)
// Load user's group ceiling
groupCeiling, _ := store.GetGroupCeiling(ctx, user.ID)
// Load server ceiling (cached in memory)
serverCeiling := appState.ServerCeiling
// Compute effective tools NOW
effectiveTools := permission.ComputeEffectiveTools(
agent.AllowedTools,
user.AllowedTools,
groupCeiling,
serverCeiling,
user.Role,
)
// Bake into JWT
agentToken, _ := auth.IssueAgentToken(
user.Username,
user.ID,
agent.Name,
agent.ID,
agent.PermissionsVersion,
effectiveTools, // **BAKED HERE**
appState.JWTSecret,
)
w.JSON(200, map[string]interface{}{
"agent_token": agentToken,
"effective_tools": effectiveTools,
})
}
Key: Computation happens once at token issuance, result is baked into JWT claims.
Permission Changes: Invalidation
When User's Tools Change
Admin executes:
UPDATE users SET allowed_tools = '["web_search"]' WHERE id = 'user_123';
Effect:
- Existing access tokens remain valid (they don't contain permission details)
- Next agent token request will use NEW user.allowed_tools
- Existing agent tokens still have old effective_tools baked in
If agent has on_permission_change = "abort":
- Check
agent.permissions_versionagainst cached state - If version changed, reject request (401)
If agent has on_permission_change = "drain":
- Check version, but allow request
- Set X-Permissions-Changed header
- Client should know permissions changed after this request
When Agent's Tools Change
Admin executes:
UPDATE agents
SET allowed_tools = '["calculator"]', permissions_version = permissions_version + 1
WHERE id = 'agent_123';
Effect:
permissions_versionincrements- Existing agent tokens have old
permissions_versionbaked in - When agent requests a query, middleware checks:
- Load agent from cache
- Compare
claims.permissions_versionvsagent.permissions_version - If mismatch, agent permissions have changed
Handling:
// In RequireAgentAuth middleware
cached, _ := agentCache.Get(ctx, claims.AgentID, loader)
if cached.PermissionsVersion != claims.PermissionsVersion {
if agent.OnPermissionChange == "abort" {
httpError(w, 401, "permissions changed") // Strict
return
}
w.Header().Set("X-Permissions-Changed", "true") // Graceful
}
Orchestrator Validation
When agent token reaches orchestrator with effective_tools baked in:
message QueryRequest {
string prompt = 1;
string agent_id = 2;
string session_id = 3;
string user_id = 4;
repeated string effective_tools = 5; // From baked JWT
}
Orchestrator:
// In orchestrator (Rust CE code)
let available_tools: Vec<String> = effective_tools
.iter()
.filter(|t| tools_registry.contains(t))
.collect();
// When LLM decides to call a tool
if !effective_tools.contains(&tool_name) {
return Err("Tool not in effective_tools list")
}
Tool must be in the baked effective_tools list, else request is rejected.
Performance: No Runtime Computation
Why this design matters:
- Token issuance: ~10-50ms (includes 1 DB query for user tools)
- Query execution: 0ms for permission computation (just validates against baked list)
- Orchestrator: 0ms for permission computation (list already in request)
Compare to alternatives:
- Real-time computation: Every query would require N DB lookups (user + agent + group + server)
- Caching: Complex invalidation strategy, stale permissions possible
- Baking: Simple, fast, snapshot-at-mint semantics
References
- Implementation:
api-go/internal/permission/compute.go - Token minting:
api-go/internal/routes/auth.go(handleAgentToken) - Validation:
api-go/internal/middleware/auth.go(RequireAgentAuth) - Schema:
api-go/internal/db/migrations.go(users, agents, groups tables)