AI Action Authorization
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.getpattern) 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.