Modeling Task-Based Authorization for Agents
Agents need credentials to interact with APIs. These can be user or service credentials, for internal or third-party systems. In most cases, those permissions grant agents broad access to the system, as the authorization services that issue credentials for those APIs do not support granting fine-grained permissions. Consent prompts and service credentials are too coarse.
Task-Based Authorization grants agents access to perform specific actions only when necessary, without permanent permissions. Agents start with zero permissions and receive only what a given task requires. For example, rather than allowing an agent to create tickets across all projects, you authorize it to "Create a ticket on project X" — scoping permissions to a specific context.
This guide shows how to model task-based authorization in OpenFGA, progressing from a basic tool-calling model to patterns that support permission hierarchies, session scoping, expiration, and agent binding.
Tool authorization
The simplest model represents tool authorization for a Model Context Protocol server. When a task starts, you write tuples granting it permission to call the tools it needs.
model
schema 1.1
type task
type tool
relations
define can_call: [task, task:*]
type tool_resource
relations
define tool: [tool]
define can_call: [task] or can_call from tool
A tool represents a capability (e.g. slack_send_message), and a tool_resource represents a specific target within that tool (e.g. a Slack channel). Granting can_call on a tool automatically grants access to all its resources. You can also grant access to individual resources, or use task:* to allow any task to call a tool.
For example, you can grant task:1 access to send any Slack message, while restricting task:2 to a specific channel:
tuples:
# Any task can list Slack channels
- user: task:*
relation: can_call
object: tool:slack_list_channels
# task:1 can send any Slack message
- user: task:1
relation: can_call
object: tool:slack_send_message
# task:2 can only send messages to channel XGA14FG
- user: task:2
relation: can_call
object: tool_resource:slack_send_message/XGA14FG
When checking whether task:2 can call tool_resource:slack_send_message/XGA14FG, send a contextual tuple linking the resource to its tool. This avoids storing a tuple for every channel — you provide the tool-to-resource relationship at query time.
- Node.js
- Go
- .NET
- Python
- Java
- CLI
- curl
- Pseudocode
- Playground
// Run a check
const { allowed } = await fgaClient.check({
user: 'task:2',
relation: 'can_call',
object: 'tool_resource:slack_send_message/XGA14FG',
contextualTuples: [
{"_description":"Link the tool_resource to its parent tool","user":"tool:slack_send_message","relation":"tool","object":"tool_resource:slack_send_message/XGA14FG"}
],
}, {
authorizationModelId: '01HVMMBCMGZNT3SED4Z17ECXCA',
});
// allowed = true
options := ClientCheckOptions{
AuthorizationModelId: openfga.PtrString("01HVMMBCMGZNT3SED4Z17ECXCA"),
}
body := ClientCheckRequest{
User: "task:2",
Relation: "can_call",
Object: "tool_resource:slack_send_message/XGA14FG",
ContextualTuples: []ClientTupleKey{
{
User: "tool:slack_send_message",
Relation: "tool",
Object: "tool_resource:slack_send_message/XGA14FG",
},
},
}
data, err := fgaClient.Check(context.Background()).
Body(body).
Options(options).
Execute()
// data = { allowed: true }
var options = new ClientCheckOptions {
AuthorizationModelId = "01HVMMBCMGZNT3SED4Z17ECXCA"
};
var body = new ClientCheckRequest {
User = "task:2",
Relation = "can_call",
Object = "tool_resource:slack_send_message/XGA14FG",
ContextualTuples = new List<ClientTupleKey> {
new(user: "tool:slack_send_message", relation: "tool", _object: "tool_resource:slack_send_message/XGA14FG")
}
};
var response = await fgaClient.Check(body, options);
// response.Allowed = true
options = {
"authorization_model_id": "01HVMMBCMGZNT3SED4Z17ECXCA",
}
body = ClientCheckRequest(
user="task:2",
relation="can_call",
object="tool_resource:slack_send_message/XGA14FG",
contextual_tuples=[
ClientTuple(user="tool:slack_send_message", relation="tool", object="tool_resource:slack_send_message/XGA14FG")
],
)
response = await fga_client.check(body, options)
# response.allowed = true
var options = new ClientCheckOptions()
.authorizationModelId("01HVMMBCMGZNT3SED4Z17ECXCA");
var body = new ClientCheckRequest()
.user("task:2")
.relation("can_call")
._object("tool_resource:slack_send_message/XGA14FG")
.contextualTuples(
List.of(
new ClientTupleKey()
.user("tool:slack_send_message")
.relation("tool")
._object("tool_resource:slack_send_message/XGA14FG")
));
var response = fgaClient.check(body, options).get();
// response.getAllowed() = true
fga query check --store-id=$FGA_STORE_ID --model-id=01HVMMBCMGZNT3SED4Z17ECXCA task:2 can_call tool_resource:slack_send_message/XGA14FG --contextual-tuple "tool:slack_send_message tool tool_resource:slack_send_message/XGA14FG"
# Response: {"allowed":true}
curl -X POST $FGA_API_URL/stores/$FGA_STORE_ID/check \
-H "Authorization: Bearer $FGA_API_TOKEN" \ # Not needed if service does not require authorization
-H "content-type: application/json" \
-d '{"authorization_model_id": "01HVMMBCMGZNT3SED4Z17ECXCA", "tuple_key":{"user":"task:2","relation":"can_call","object":"tool_resource:slack_send_message/XGA14FG"},"contextual_tuples":{"tuple_keys":[{"user":"tool:slack_send_message","relation":"tool","object":"tool_resource:slack_send_message/XGA14FG"}]}}'
# Response: {"allowed":true}
check(
user = "task:2", // check if the user `task:2`
relation = "can_call", // has an `can_call` relation
object = "tool_resource:slack_send_message/XGA14FG", // with the object `tool_resource:slack_send_message/XGA14FG`
contextual_tuples = [ // Assuming the following is true
{user = "tool:slack_send_message", relation = "tool", object = "tool_resource:slack_send_message/XGA14FG"}
], authorization_id = "01HVMMBCMGZNT3SED4Z17ECXCA"
);
Reply: true
is task:2 related to tool_resource:slack_send_message/XGA14FG as can_call?
# Note: Contextual Tuples are not supported on the playground
# Response: A green path from the user to the object indicating that the response from the API is `{"allowed":true}`
Domain-specific models
The model above is generic. If you are building agents for your own application, the authorization model should reflect your application's domain. Consider a project management system:
model
schema 1.1
type user
type task
type organization
relations
define admin: [user]
define member: [user]
type project
relations
define organization: [organization]
# relations for users
define owner: [user]
define member: [user]
# relations for tasks
define read: [task]
define write: [task]
define delete: [task]
# permissions for users
define can_delete: delete or owner or admin from organization
define can_edit: write or owner or admin from organization
define can_read: read or can_edit or member from organization
define can_create_ticket: can_edit
type ticket
relations
define project: [project]
define owner: [user]
# relations for tasks
define read: [task]
define write: [task]
define delete: [task]
define can_delete: owner or delete or can_delete from project
define can_edit: owner or write or can_edit from project
define can_read: read or write or can_read from project
Here we enrich an existing user-oriented model by adding task as a principal. Granting a task the write relation on a project gives it permission to read/edit the project and all its tickets. You can also grant permissions at the ticket level for more granular control. This makes your application ready for agent authorization with minimal changes to your existing model.
Scoping permissions to sessions and agents
In interactive agents, users may create multiple sessions. You can scope permissions to a session so that granting access applies to all tasks within it. You can also scope permissions to an agent, so they persist across sessions.
model
schema 1.1
type task
type agent
relations
define task: [task]
type session
relations
define task: [task]
type tool
relations
define can_call: [task, session#task, agent#task]
The can_call relation accepts three types of assignments:
task— grant permission to a specific task.session#task— grant permission to all tasks in a session. When the user says "allow this for this session", write a tuple likeuser: session:1#task, relation: can_call, object: tool:slack_send_message.agent#task— grant permission to all tasks for an agent, across sessions. When the user says "always allow this", write a tuple withagent:1#taskinstead.
Each task is linked to its session and agent when created:
tuples:
# Link task to its agent and session
- user: task:1
relation: task
object: agent:1
- user: task:1
relation: task
object: session:1
# Grant session-level access
- user: session:1#task
relation: can_call
object: tool:slack_send_message
Expiration and turn count
You can use OpenFGA conditions to make permissions expire after a duration or a number of agent turns.
model
schema 1.1
type task
type tool
relations
define can_call: [task, task with expiration, task with turn_count]
condition expiration(grant_time: timestamp, grant_duration: duration, current_time: timestamp) {
current_time < grant_time + grant_duration
}
condition turn_count(turns_granted: int, current_turn: int) {
current_turn <= turns_granted
}
The expiration condition grants access for a fixed duration from the grant time. The turn_count condition grants access for a fixed number of agent turns. When writing the tuple, you provide the condition parameters:
tuples:
# task:1 can call the tool for 10 minutes
- user: task:1
relation: can_call
object: tool:slack_send_message
condition:
name: expiration
context:
grant_time: "2026-03-22T00:00:00Z"
grant_duration: 10m
# task:2 can call the tool for 2 turns
- user: task:2
relation: can_call
object: tool:slack_send_message
condition:
name: turn_count
context:
turns_granted: 2
When checking access, pass the current time or turn number in the request context.
Binding agents to tasks
The examples above don't verify that the agent making the call is actually assigned to the task. You can enforce this using contextual tuples and an intersection (and) in the model, similar to the Authorization Through Organization Context pattern.
model
schema 1.1
type task
type agent
relations
define task: [task]
type tool
relations
define agent_in_context: [agent]
define can_call: [task] and task from agent_in_context
The can_call relation requires both that the task has been granted access and that the agent making the call is linked to the task. When the task is created, link it to its agent:
tuples:
- user: task:1
relation: task
object: agent:1
- user: task:1
relation: can_call
object: tool:slack_send_message
At check time, send a contextual tuple identifying the calling agent. If the agent is linked to the task, the check returns true:
- Node.js
- Go
- .NET
- Python
- Java
- CLI
- curl
- Pseudocode
- Playground
// Run a check
const { allowed } = await fgaClient.check({
user: 'task:1',
relation: 'can_call',
object: 'tool:slack_send_message',
contextualTuples: [
{"_description":"The agent making the call","user":"agent:1","relation":"agent_in_context","object":"tool:slack_send_message"}
],
}, {
authorizationModelId: '01HVMMBCMGZNT3SED4Z17ECXCA',
});
// allowed = true
options := ClientCheckOptions{
AuthorizationModelId: openfga.PtrString("01HVMMBCMGZNT3SED4Z17ECXCA"),
}
body := ClientCheckRequest{
User: "task:1",
Relation: "can_call",
Object: "tool:slack_send_message",
ContextualTuples: []ClientTupleKey{
{
User: "agent:1",
Relation: "agent_in_context",
Object: "tool:slack_send_message",
},
},
}
data, err := fgaClient.Check(context.Background()).
Body(body).
Options(options).
Execute()
// data = { allowed: true }
var options = new ClientCheckOptions {
AuthorizationModelId = "01HVMMBCMGZNT3SED4Z17ECXCA"
};
var body = new ClientCheckRequest {
User = "task:1",
Relation = "can_call",
Object = "tool:slack_send_message",
ContextualTuples = new List<ClientTupleKey> {
new(user: "agent:1", relation: "agent_in_context", _object: "tool:slack_send_message")
}
};
var response = await fgaClient.Check(body, options);
// response.Allowed = true
options = {
"authorization_model_id": "01HVMMBCMGZNT3SED4Z17ECXCA",
}
body = ClientCheckRequest(
user="task:1",
relation="can_call",
object="tool:slack_send_message",
contextual_tuples=[
ClientTuple(user="agent:1", relation="agent_in_context", object="tool:slack_send_message")
],
)
response = await fga_client.check(body, options)
# response.allowed = true
var options = new ClientCheckOptions()
.authorizationModelId("01HVMMBCMGZNT3SED4Z17ECXCA");
var body = new ClientCheckRequest()
.user("task:1")
.relation("can_call")
._object("tool:slack_send_message")
.contextualTuples(
List.of(
new ClientTupleKey()
.user("agent:1")
.relation("agent_in_context")
._object("tool:slack_send_message")
));
var response = fgaClient.check(body, options).get();
// response.getAllowed() = true
fga query check --store-id=$FGA_STORE_ID --model-id=01HVMMBCMGZNT3SED4Z17ECXCA task:1 can_call tool:slack_send_message --contextual-tuple "agent:1 agent_in_context tool:slack_send_message"
# Response: {"allowed":true}
curl -X POST $FGA_API_URL/stores/$FGA_STORE_ID/check \
-H "Authorization: Bearer $FGA_API_TOKEN" \ # Not needed if service does not require authorization
-H "content-type: application/json" \
-d '{"authorization_model_id": "01HVMMBCMGZNT3SED4Z17ECXCA", "tuple_key":{"user":"task:1","relation":"can_call","object":"tool:slack_send_message"},"contextual_tuples":{"tuple_keys":[{"user":"agent:1","relation":"agent_in_context","object":"tool:slack_send_message"}]}}'
# Response: {"allowed":true}
check(
user = "task:1", // check if the user `task:1`
relation = "can_call", // has an `can_call` relation
object = "tool:slack_send_message", // with the object `tool:slack_send_message`
contextual_tuples = [ // Assuming the following is true
{user = "agent:1", relation = "agent_in_context", object = "tool:slack_send_message"}
], authorization_id = "01HVMMBCMGZNT3SED4Z17ECXCA"
);
Reply: true
is task:1 related to tool:slack_send_message as can_call?
# Note: Contextual Tuples are not supported on the playground
# Response: A green path from the user to the object indicating that the response from the API is `{"allowed":true}`
If a different agent tries to use task:1, the check returns false because the agent-to-task link won't match:
- Node.js
- Go
- .NET
- Python
- Java
- CLI
- curl
- Pseudocode
- Playground
// Run a check
const { allowed } = await fgaClient.check({
user: 'task:1',
relation: 'can_call',
object: 'tool:slack_send_message',
contextualTuples: [
{"_description":"A different agent making the call","user":"agent:2","relation":"agent_in_context","object":"tool:slack_send_message"}
],
}, {
authorizationModelId: '01HVMMBCMGZNT3SED4Z17ECXCA',
});
// allowed = false
options := ClientCheckOptions{
AuthorizationModelId: openfga.PtrString("01HVMMBCMGZNT3SED4Z17ECXCA"),
}
body := ClientCheckRequest{
User: "task:1",
Relation: "can_call",
Object: "tool:slack_send_message",
ContextualTuples: []ClientTupleKey{
{
User: "agent:2",
Relation: "agent_in_context",
Object: "tool:slack_send_message",
},
},
}
data, err := fgaClient.Check(context.Background()).
Body(body).
Options(options).
Execute()
// data = { allowed: false }
var options = new ClientCheckOptions {
AuthorizationModelId = "01HVMMBCMGZNT3SED4Z17ECXCA"
};
var body = new ClientCheckRequest {
User = "task:1",
Relation = "can_call",
Object = "tool:slack_send_message",
ContextualTuples = new List<ClientTupleKey> {
new(user: "agent:2", relation: "agent_in_context", _object: "tool:slack_send_message")
}
};
var response = await fgaClient.Check(body, options);
// response.Allowed = false
options = {
"authorization_model_id": "01HVMMBCMGZNT3SED4Z17ECXCA",
}
body = ClientCheckRequest(
user="task:1",
relation="can_call",
object="tool:slack_send_message",
contextual_tuples=[
ClientTuple(user="agent:2", relation="agent_in_context", object="tool:slack_send_message")
],
)
response = await fga_client.check(body, options)
# response.allowed = false
var options = new ClientCheckOptions()
.authorizationModelId("01HVMMBCMGZNT3SED4Z17ECXCA");
var body = new ClientCheckRequest()
.user("task:1")
.relation("can_call")
._object("tool:slack_send_message")
.contextualTuples(
List.of(
new ClientTupleKey()
.user("agent:2")
.relation("agent_in_context")
._object("tool:slack_send_message")
));
var response = fgaClient.check(body, options).get();
// response.getAllowed() = false
fga query check --store-id=$FGA_STORE_ID --model-id=01HVMMBCMGZNT3SED4Z17ECXCA task:1 can_call tool:slack_send_message --contextual-tuple "agent:2 agent_in_context tool:slack_send_message"
# Response: {"allowed":false}
curl -X POST $FGA_API_URL/stores/$FGA_STORE_ID/check \
-H "Authorization: Bearer $FGA_API_TOKEN" \ # Not needed if service does not require authorization
-H "content-type: application/json" \
-d '{"authorization_model_id": "01HVMMBCMGZNT3SED4Z17ECXCA", "tuple_key":{"user":"task:1","relation":"can_call","object":"tool:slack_send_message"},"contextual_tuples":{"tuple_keys":[{"user":"agent:2","relation":"agent_in_context","object":"tool:slack_send_message"}]}}'
# Response: {"allowed":false}
check(
user = "task:1", // check if the user `task:1`
relation = "can_call", // has an `can_call` relation
object = "tool:slack_send_message", // with the object `tool:slack_send_message`
contextual_tuples = [ // Assuming the following is true
{user = "agent:2", relation = "agent_in_context", object = "tool:slack_send_message"}
], authorization_id = "01HVMMBCMGZNT3SED4Z17ECXCA"
);
Reply: false
is task:1 related to tool:slack_send_message as can_call?
# Note: Contextual Tuples are not supported on the playground
# Response: A red object indicating that the response from the API is `{"allowed":false}`
Delegating task permissions to sub-agents
Sub-agents also start with zero permissions. When delegating work, you have two options:
- Share the task: assign the same task to the sub-agent, giving it all the task's permissions.
- Restrict further: create a new task with a narrower set of permissions for the sub-agent.
Further reading
Mapping user intent to the right set of permissions is an active area of research. These resources explore the topic:
- Intent-Based Access Control: Securing Agentic AI Through Fine-Grained Authorization
- Delegated Authorization for Agents Constrained to Semantic Task-to-Scope Matching
- The Mission Shaping Problem
- Securing Agentic AI: authorization patterns for autonomous systems