Skip to main content

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:

  1. Existing access tokens remain valid (they don't contain permission details)
  2. Next agent token request will use NEW user.allowed_tools
  3. Existing agent tokens still have old effective_tools baked in

If agent has on_permission_change = "abort":

  • Check agent.permissions_version against 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:

  1. permissions_version increments
  2. Existing agent tokens have old permissions_version baked in
  3. When agent requests a query, middleware checks:
    • Load agent from cache
    • Compare claims.permissions_version vs agent.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)