AI Action Authorization

Ma Mahalakshmi V Updated 19 Sep 2026
7 min read

User permissions and authorization for AI actions

Lesson 6 of this unit asked "are these tool arguments well-formed and within acceptable bounds?" This lesson asks a different, prior question: is this specific user, in this specific context, allowed to trigger this tool at all? That question is authorization, and it is easy to skip in an AI application because the model's own fluency in following instructions can create a false impression that it also enforces permissions — it does not. The model has no inherent concept of your organization's role hierarchy or your application's access control rules unless your code enforces them independently of anything the model decides.

Authentication vs. authorization

These two terms are frequently conflated but answer different questions:

  • Authentication answers "who is making this request?" — typically resolved before the model is ever involved, through a login session, an API token, or similar.
  • Authorization answers "is this authenticated identity allowed to do this specific thing?" — and this is the question that matters once a tool call is about to execute a real action.

An application can have perfect authentication (it knows exactly which user is talking to the assistant) and still have a serious security gap if it never checks authorization before executing a tool call the model decided to make. Unit 8 introduced tools as the mechanism for connecting a model to real actions; Unit 11 introduced agents that can chain several tool calls autonomously. Neither of those units addressed whether the user behind a given conversation should be allowed to trigger a given tool — that gap is exactly what this lesson closes.

Why the model cannot be the authorization boundary

It's tempting to handle permissions by prompt instruction: "Only allow managers to approve refunds." This can shape behavior, but it is not enforcement, for the same underlying reason Lesson 4 gave for why validation belongs in code rather than in a polite request: prompt instructions are probabilistic guidance, not a hard boundary, and they are exactly the kind of instruction a prompt injection attack (Lesson 4) targets first. If your only refund-approval gate is "the model was told only managers can do this," a cleverly crafted message — from a non-manager user, or embedded in untrusted content the model processes — has a real chance of bypassing it. Authorization must be checked in code, deterministically, using information your application controls (the authenticated user's actual role, looked up from your own user database) — never solely from something the model infers or is told within the conversation.

Role-based access control for tools

A practical, well-understood pattern is role-based access control (RBAC): define a set of roles, and for each tool, define which roles may invoke it. The check happens after the model proposes a tool call but before your code executes it.

from dataclasses import dataclass
from enum import Enum


class Role(str, Enum):
    CUSTOMER = "customer"
    SUPPORT_AGENT = "support_agent"
    MANAGER = "manager"


@dataclass
class User:
    user_id: str
    role: Role


# Maps each tool name to the set of roles allowed to invoke it.
TOOL_PERMISSIONS: dict[str, set[Role]] = {
    "lookup_order_status": {Role.CUSTOMER, Role.SUPPORT_AGENT, Role.MANAGER},
    "issue_refund": {Role.SUPPORT_AGENT, Role.MANAGER},
    "delete_customer_account": {Role.MANAGER},
}


class AuthorizationError(Exception):
    """Raised when a user is not permitted to invoke a given tool."""


def authorize_tool_call(user: User, tool_name: str) -> None:
    allowed_roles = TOOL_PERMISSIONS.get(tool_name)
    if allowed_roles is None:
        # Fail closed: an unrecognized tool name is never authorized,
        # rather than defaulting to "allowed."
        raise AuthorizationError(f"Unknown tool '{tool_name}' has no permission entry")
    if user.role not in allowed_roles:
        raise AuthorizationError(
            f"User role '{user.role.value}' is not permitted to call '{tool_name}'"
        )

The TOOL_PERMISSIONS.get(tool_name) check returning None for an unrecognized tool, and treating that as a rejection rather than a pass, is a deliberate fail-closed design: a new tool added to the system without an accompanying permissions entry is inaccessible by default, rather than accidentally open to everyone. This is the opposite of, and safer than, a "deny list" approach where a tool is available unless explicitly restricted.

def test_customer_can_lookup_order():
    user = User(user_id="u1", role=Role.CUSTOMER)
    authorize_tool_call(user, "lookup_order_status")  # should not raise
    print("PASS: a customer can look up order status")


def test_customer_cannot_issue_refund():
    user = User(user_id="u1", role=Role.CUSTOMER)
    try:
        authorize_tool_call(user, "issue_refund")
        raised = False
    except AuthorizationError:
        raised = True
    assert raised
    print("PASS: a customer is blocked from issuing refunds")


def test_unknown_tool_fails_closed():
    user = User(user_id="u1", role=Role.MANAGER)
    try:
        authorize_tool_call(user, "some_new_tool_without_permissions")
        raised = False
    except AuthorizationError:
        raised = True
    assert raised
    print("PASS: an unregistered tool is rejected even for a manager")


test_customer_can_lookup_order()
test_customer_cannot_issue_refund()
test_unknown_tool_fails_closed()

Authorization must be scoped to the resource, not just the action

Role-based checks answer "can a user with this role ever call this tool?" — but many real applications need a finer-grained check: "can this specific user act on this specific resource?" A support agent might be allowed to issue refunds in general, but only for orders belonging to customers in their assigned region, not for arbitrary orders anywhere in the system. This second layer is often called resource-level or object-level authorization, and it must be checked in addition to the role check, using data about the specific arguments the tool call carries.

@dataclass
class Order:
    order_id: str
    region: str


def authorize_refund_for_order(user: User, order: Order, agent_region: str | None) -> None:
    authorize_tool_call(user, "issue_refund")  # role-level check first
    if user.role == Role.SUPPORT_AGENT and order.region != agent_region:
        raise AuthorizationError(
            f"Agent for region '{agent_region}' cannot refund an order "
            f"in region '{order.region}'"
        )
    # Managers are not region-restricted in this policy.
def test_agent_can_refund_own_region_order():
    agent = User(user_id="a1", role=Role.SUPPORT_AGENT)
    order = Order(order_id="ord_1", region="EU")
    authorize_refund_for_order(agent, order, agent_region="EU")  # should not raise
    print("PASS: an agent can refund an order in their own region")


def test_agent_cannot_refund_other_region_order():
    agent = User(user_id="a1", role=Role.SUPPORT_AGENT)
    order = Order(order_id="ord_2", region="APAC")
    try:
        authorize_refund_for_order(agent, order, agent_region="EU")
        raised = False
    except AuthorizationError:
        raised = True
    assert raised
    print("PASS: an agent cannot refund an order outside their region")


test_agent_can_refund_own_region_order()
test_agent_cannot_refund_other_region_order()

This two-layer structure — role check, then resource-scoped check — mirrors how most real production authorization systems work, and it composes naturally with the tool-argument validation from Lesson 6: validation confirms the arguments are well-formed and in range; authorization confirms this user is allowed to act on this particular resource with them. Both must pass before execution proceeds.

Human-in-the-loop approval for high-risk actions

For actions with significant, hard-to-reverse consequences — deleting an account, issuing a large refund, sending a message to an external party — role-based and resource-scoped authorization can be supplemented with an explicit human confirmation step, regardless of role. This means the tool call is not executed immediately when the model proposes it; instead, your application surfaces the proposed action to a human (the end user, or a supervising staff member) and only executes it after explicit approval.

@dataclass
class PendingAction:
    tool_name: str
    arguments: dict
    requires_confirmation: bool


HIGH_RISK_TOOLS = {"delete_customer_account", "issue_refund"}


def prepare_action(tool_name: str, arguments: dict) -> PendingAction:
    return PendingAction(
        tool_name=tool_name,
        arguments=arguments,
        requires_confirmation=tool_name in HIGH_RISK_TOOLS,
    )

A PendingAction with requires_confirmation=True should be held by your application and only executed after a human explicitly confirms it — through a UI prompt, a confirmation message, or an equivalent step appropriate to your application. This is a deliberate slowdown, and it's justified specifically for actions where an authorization or validation gap, or a successful prompt injection that neither Lesson 4's mitigations nor this lesson's checks fully caught, would otherwise cause irreversible harm.

Common Mistakes

  • Relying on prompt instructions as the sole mechanism for restricting who can trigger a sensitive tool. As with validation, this is guidance the model follows probabilistically, not an enforced boundary, and it is exactly the surface a prompt injection attack targets.
  • Checking role-based permissions but skipping resource-level checks. A support agent authorized to issue refunds "in general" without a per-order check can refund any order in the system, not just ones they're actually responsible for.
  • Defaulting new tools to "allowed" until someone remembers to restrict them. This is a fail-open design; a fail-closed default (Lesson's TOOL_PERMISSIONS.get pattern) is safer because a forgotten permissions entry results in a denial, not an open door.

Best Practices

  • Enforce authorization in code, after the model proposes a tool call and before your code executes it — never treat a prompt instruction as sufficient enforcement.
  • Check both role-level and resource-level authorization for any tool acting on a specific record, account, or entity.
  • Fail closed by default: an unrecognized tool or an ambiguous permission state should be denied, not allowed.
  • Add human-in-the-loop confirmation for high-risk, hard-to-reverse actions, as a layer independent of (not a replacement for) automated authorization checks.

0 Comments

Reviewed before they appear

No comments yet.

OpenAI SDK
Introduction to the OpenAI SDK Setting Up Python Creating an API Key Your First Call — client.responses.create() and response.out Understanding Billing, Credits, and What a Request Costs Why Responses Replaced Chat Completions Anatomy of a Request: model, input, and instructions Anatomy of a Response: The Typed output Array, Not Just Text Roles: User, Assistant, and Developer/System Choosing a Model, and Reading the Models Page Instead of Mem Instructions vs. Input Writing Prompts That Get Consistent Results Few-Shot Examples Reasoning Models and the reasoning Parameter Debugging a Prompt That Misbehaves Why Streaming Matters for User Experience stream=True and Iterating Over Events Handling the Event Types You Actually Care About Background Mode for Long-Running Jobs Project — Add Live Streaming to Your Chatbot The Problem With Parsing Free Text JSON Schema and Strict Mode Pydantic Models With the SDK's Parse Helpers Handling Refusals and Validation Failures Project — A Resume-to-JSON Extractor Working With input_image input_file, PDFs, and the Files API Image Generation Speech-to-Text and Text-to-Speech Project: A PDF Question-Answering Script What Function Calling Is Defining a Tool Schema The Full Loop Multiple Tools Errors, Timeouts, and Untrusted Arguments Project: A Weather Assistant Web Search File Search and Vector Stores Code Interpreter Remote MCP Servers and Connectors Project: A Research Assistant What an Embedding Is, Without the Maths Generating and Storing Embeddings Similarity Search From Scratch Hosted Vector Stores vs. Rolling Your Own A Small RAG App Over a Folder of Notes Agents vs. a Single API Call — When You Need One pip install openai Giving Agents Tools Handoffs and Multi-Agent Triage Guardrails and Approvals Tracing and Observing What Your Agent Did A Multi-Agent Support Desk Error Codes and What Each One Means Retries, Timeouts, and Backoff Rate Limits and Spend Limits Prompt Caching and Cost Optimisation The Batch API for Bulk Work Async Clients and Concurrency Moderation and Safety Best Practices Designing the App Backend With FastAPI Streaming to a Simple Frontend Deploying and a Cost/Safety Checklist Why Web Search Is Useful for Current Information Using the Web Search Tool with the Responses API Configuring Search Behavior for Application Use Cases Understanding Citations and Source Attribution Where to Go Next Building a Research Assistant with Web Search Combining Web Search with Structured Outputs Handling Conflicting or Low-Quality Web Sources
Ask about this post
AI Ask about this post

Ask questions about AI Action Authorization and get answers drawn from it.

Signed-in readers only.