Cloud Engineer Lab
Cloud Engineer Lab
Cloud Engineer Lab
Cloud Engineer Lab
© 2026
AI Agent Permissions: Build Least-Privilege Access for Tools and APIs
AI & InnovationIntermediate

AI Agent Permissions: Build Least-Privilege Access for Tools and APIs

A working permission layer for an AI agent: scoped per-call credentials, per-tool permissions, a policy decision point, approvals bound to exact parameters, and a tamper-evident audit log.

15 min read
Share

The diagram on this post's cover shows the question every agent tool call should answer before anything happens: "Can the AI perform this action?" Sam asks the agent to schedule a meeting with Robert. The request goes through MCP, but before it reaches the calendar, a policy decision point (PDP) checks who is asking, which tool, which resource, and what action. Only then does the call go through.

This post builds that layer for a real agent and tests it. It has five parts:

  1. Scoped credentials: a token that allows exactly one call, on one resource, for two minutes.
  2. Per-tool permissions: the agent can do only what both the user and the agent are allowed to do.
  3. Authorization checks: one PDP, asked before every call, never skipped.
  4. Approval workflows: human approval bound to the exact parameters approved, single-use and expiring.
  5. Audit logs: an append-only record where any edit is detected.

How to give AI agents API access without too much power covers the principles: one tool per capability, tiers by blast radius, and rate limits. AI agent identity covers delegated identity. This post is the hands-on build that puts them together.

What we built and tested

  • The code: a permission layer for the IT helpdesk agent used in earlier posts, in plain Python (about 170 lines, standard library only).
  • The tests: 20 authorization test cases with expected decisions, and 18 checks of the credential, approval and audit mechanisms.
  • The results: the policy matched all 20 expected decisions, and all 18 checks passed on Python 3.14. A PDP decision took about 1 to 2 microseconds; minting and checking a token took about 15 to 22.

It's built to show the mechanics clearly, not as a drop-in product. The section near the end maps each part to what you'd use in production.

The Shortcut This Replaces

Most agents start the same way. The agent gets one service account with every permission its tools might ever need, and anything the agent decides to do, it can do.

The problem is in whose permissions are used. Our test users are:

  • Sam: an ordinary employee.
  • Priya: on the helpdesk team.
  • Omar: the helpdesk lead.

If the agent's own identity decides, Sam can ask the agent to restart a colleague's laptop, edit any ticket, or put someone in a group, because the agent is allowed to. The agent becomes a way for anyone to borrow its access.

We measured it. Of our 20 test cases, 15 should not simply be allowed: they should be denied or need approval. "The agent's identity decides" would have allowed 13 of those 15. The only two it stopped were calls to a tool the agent was never given.

Part 1: Per-Tool Permissions, Calculated Per Request

The fix is to calculate permissions for this request: what this user is allowed to do, intersected with what this agent is allowed to do.

Is the tool in this agent's grant? If not, deny
Is the user allowed this action on this resource (any, or their own)? If not, deny
Do resource rules forbid it (a protected group, no identity check)? If so, deny
Is it a privileged action? If so, it needs approval
Otherwise, allow

User rights come from roles. "own" means the user's own devices and tickets, while "any" means anyone's:

python
ROLE_ACTIONS = {
    "employee":      {"get_ticket:own", "get_device:own", "restart_service:own", "clear_temp_files:own"},
    "helpdesk":      {"get_ticket:any", "get_device:any", "restart_service:any", "clear_temp_files:any",
                      "update_ticket:any", "add_to_group:any", "reset_password:any"},
    "helpdesk_lead": {"approve:any"},
}
AGENT_GRANTS = {    # what each agent identity is allowed to use, whoever is asking
    "helpdesk-agent": {"get_ticket", "get_device", "restart_service", "clear_temp_files", "update_ticket",
                       "add_to_group", "reset_password"},
}

Notice what isn't in the agent's grant: disable_account. Offboarding happens in the HR workflow, so this agent never gets that tool, whoever asks. Removing a tool is still the strongest permission, as the threat model for this agent showed.

Part 2: The Policy Decision Point

All the rules live in one function. The tools never decide for themselves whether they're allowed to run:

python
def decide(user, agent, action, resource, context=None):
    """Return (decision, reason). decision is 'allow', 'deny' or 'approval_required'."""
    context = context or {}
    if action not in AGENT_GRANTS.get(agent, set()):
        return "deny", f"agent '{agent}' has no grant for {action}"
    rights = set().union(*(ROLE_ACTIONS[r] for r in USERS.get(user, {}).get("roles", [])))
    owns = OWNERS.get(resource.get("id")) == user
    if f"{action}:any" not in rights and not (owns and f"{action}:own" in rights):
        return "deny", f"user '{user}' may not {action} on {resource.get('id')}"
    if action == "add_to_group" and resource.get("group") in PROTECTED_GROUPS:
        return "deny", f"'{resource['group']}' is a protected group; use a change request"
    if action == "reset_password" and not context.get("identity_verified"):
        return "deny", "no recorded identity verification for this reset"
    if action in NEEDS_APPROVAL:
        return "approval_required", f"{action} needs a second person"
    return "allow", "within the user's rights and the agent's grant"

Three things make this a real PDP rather than a few if statements inside each tool:

  • It's the only place rules exist. To answer "who can reset passwords?", you read one function, not thirteen tools.
  • It looks at the resource, not just the action. Sam may restart PC-0142 (his laptop) but not PC-0377 (Priya's). A role check alone can't express that.
  • It explains itself. Every decision comes with a reason, which goes into the audit log and back to the user.

Some of the 20 test cases, with the decision the PDP gave for each:

CaseRequestDecision
C01Sam restarts the spooler on his own laptopAllow
C02Sam restarts the spooler on Priya's laptopDeny: not his device
C05Sam updates his own ticketDeny: employees can't edit tickets
C09Priya (helpdesk) restarts Sam's laptopAllow
C11Priya adds Tom to Project AtlasApproval required
C12Priya adds Tom to Domain AdminsDeny: protected group
C15Priya resets Lee's password, no verification on recordDeny
C16Priya disables Lee's accountDeny: the agent has no grant for it
C18Omar (the lead) adds himself to Domain AdminsDeny: protected group, even for the lead
C19An unknown user reads a ticketDeny

The PDP gave the expected decision in all 20 cases.

Part 3: Scoped Credentials for Every Call

The agent never holds a credential that works for everything. After the PDP allows a call, the gateway mints a token for exactly that call: this user, this agent, this action with these parameters, for two minutes, once. The tool checks the token itself before acting.

python
def scope(action, resource):
    """The exact call a token allows: the action plus every parameter, not just the resource id."""
    return action + ":" + json.dumps(resource, sort_keys=True)
 
 
def mint_token(user, agent, action, resource, ttl=120):
    claims = {"sub": user, "act": agent, "scope": scope(action, resource), "exp": time.time() + ttl,
              "jti": secrets.token_hex(8)}
    body = json.dumps(claims, sort_keys=True).encode()
    return body.hex() + "." + hmac.new(KEY, body, hashlib.sha256).hexdigest()
python
def verify_token(token, action, resource):
    """The tool's own check: signed by the gateway, unexpired, unused, and for exactly this call."""
    try:
        body_hex, sig = token.split(".")
        body = bytes.fromhex(body_hex)
    except ValueError:
        return False, "malformed token"
    if not hmac.compare_digest(sig, hmac.new(KEY, body, hashlib.sha256).hexdigest()):
        return False, "bad signature"
    c = json.loads(body)
    if c["exp"] < time.time():
        return False, "expired"
    if c["jti"] in USED_TOKENS:
        return False, "already used"
    if c["scope"] != scope(action, resource):
        return False, f"token is for {c['scope']}, not {scope(action, resource)}"
    USED_TOKENS.add(c["jti"])
    return True, "ok"

The token records two identities: sub (the user the action is for) and act (the agent acting). The audit trail can then answer "who did this?" honestly: the agent did, on Sam's behalf.

Why check the token in the tool as well as at the gateway? Because the tools are where the damage happens. If any code path ever reaches a tool without going through the gateway, the tool refuses: our test called a tool directly, and it answered "malformed token".

Our first version had a gap here, and a test found it

The first mint_token scoped the token to the action and the resource id only, for example add_to_group:tom. A token issued for "add Tom to Project Atlas" would then also have worked for "add Tom to Domain Admins", since both have the id tom. The gateway's approval check would still have stopped it, but the tool's own check wouldn't. We now bind the token to every parameter, and test this exact case:

token is for add_to_group:{"group": "Project Atlas", "id": "tom"}, not add_to_group:{"group": "Domain Admins", "id": "tom"}

Scope tokens to the whole call, not to the most obvious field.

Part 4: Approvals Bound to Exact Parameters

For privileged actions, the PDP returns approval_required. The gateway parks the call and creates an approval request. Four rules make the approval mean something:

  • The requester can't approve their own request.
  • Only approvers can approve: here, the helpdesk lead.
  • The approval is for exactly these parameters. It stores a fingerprint of the action and every parameter; a call with anything different is refused.
  • It's single-use and it expires (15 minutes here).
python
    def consume(self, rid, action, resource):
        g = self.granted.pop(rid, None)          # single use: removed whether or not it matches
        if not g:
            return False, "not approved (or already used)"
        if g["exp"] < time.time():
            return False, "approval expired"
        if g["fp"] != fingerprint(action, resource):
            return False, "parameters differ from what was approved"
        return True, g["approver"]

The fingerprint check is what stops the classic swap: getting a harmless change approved, then running a different one with the approval ID. The single-use rule matters too. In our test, an attempt with the wrong parameters used up the approval, so a second attempt with the right parameters needed a fresh approval instead of quietly succeeding.

There's one more detail in the gateway below: when an approved call finally runs, the PDP is asked again. If Priya lost her helpdesk role between asking and approval, the call is denied. Permissions are checked when the action happens, not only when it was requested. Human-in-the-Loop AI covers what a good approval request should show the approver.

Part 5: An Audit Log You Can Trust

Every decision is logged: allowed, denied and pending, with the reason and the approver. Each entry includes the hash of the previous one, so changing or deleting any entry breaks the chain:

python
    def write(self, **event):
        event = {"ts": round(time.time(), 3), **event, "prev": self.last}
        self.last = hashlib.sha256(json.dumps(event, sort_keys=True).encode()).hexdigest()
        self.entries.append({**event, "hash": self.last})

A real entry from the test run, for Sam's denied request:

text
{'ts': 1792499601.441, 'user': 'sam', 'agent': 'helpdesk-agent', 'action': 'restart_service', 'resource': {'id': 'PC-0377'}, 'decision': 'deny', 'reason': "user 'sam' may not restart_service on PC-0377", 'hash': '75d539b5909f9f643f0a635b7c9a3bb8ef67918780598a8f37afea65759ea562'}

When the test changed one old entry (an approval request marked "pending") to read "deny", verification reported entry 3 was changed or removed.

A hash chain detects edits; it doesn't prevent them

Someone who can rewrite the whole log can recompute every hash. In production, write the log to storage the agent's runtime can only append to, such as immutable blob storage or a separate logging workspace. Periodically copy the latest hash somewhere else, so a rewritten history no longer matches.

Putting It Together: the Gateway

The gateway is the only path from the agent to a tool. It asks the PDP, handles approvals, mints the scoped token, calls the tool and logs the result:

python
    def call(self, user, agent, action, resource, context=None, approval_id=None):
        decision, reason = decide(user, agent, action, resource, context)
        approver = None
        if decision == "approval_required":
            if not approval_id:
                rid = self.approvals.request(user, action, resource)
                self.log.write(user=user, agent=agent, action=action, resource=resource, decision="pending", reason=rid)
                return {"status": "pending_approval", "approval_id": rid}
            ok, approver = self.approvals.consume(approval_id, action, resource)
            if not ok:
                decision, reason = "deny", approver
        if decision == "deny":
            self.log.write(user=user, agent=agent, action=action, resource=resource, decision="deny", reason=reason)
            return {"status": "denied", "reason": reason}
        token = mint_token(user, agent, action, resource)
        result = self.tools[action](token, resource)
        self.log.write(user=user, agent=agent, action=action, resource=resource, decision="allow",
                       approver=approver, result=result.get("status"))
        return result

In an MCP setup, this is the layer between the MCP server and the real APIs, exactly where the PDP sits on the cover diagram. The model sees tool names and results. It never sees a credential, and it can't skip the check.

The Test Results

All 18 mechanism checks passed:

text
PASS  token works for its own call
PASS  same token refused a second time  already used
PASS  token refused for another device  token is for restart_service:{"id": "PC-0142"}, not restart_service:{"id": "PC-0377"}
PASS  token for one group refused for another  token is for add_to_group:{"group": "Project Atlas", "id": "tom"}, not add_to_group:{"group": "Domain Admins", "id": "tom"}
PASS  expired token refused
PASS  altered token refused  bad signature
PASS  tool refuses a call that skips the gateway  malformed token
PASS  privileged call parked for approval
PASS  requester can't approve own request  requester cannot approve their own request
PASS  non-approver can't approve
PASS  lead approves
PASS  approval refused for different parameters  parameters differ from what was approved
PASS  approval is single-use (consumed by the mismatch)  not approved (or already used)
PASS  approved call runs once
PASS  expired approval refused  approval expired
PASS  gateway denies Sam on Priya's laptop
PASS  audit log intact  8 entries intact
PASS  edited entry detected  entry 3 was changed or removed
What we measuredResult
Policy decisions matching the expected result20 / 20
Non-allowed cases the "agent identity decides" shortcut would have allowed13 / 15
Credential, approval and audit checks passed18 / 18
Time for one PDP decisionAbout 1 to 2 microseconds
Time to mint and verify one scoped tokenAbout 15 to 22 microseconds

The cost of checking every call is negligible next to a single model call, which takes seconds. Performance is no reason to skip authorization.

Keep these tests in the agent's test suite and run them on every change to roles, grants or tools. A permission bug looks exactly like a working feature until someone tests the case that should fail.

From This Example to Production

Every part maps to something you'd use in a real Microsoft or cloud environment. The names below are examples; pick what fits your platform.

PartIn this postIn production, for example
Scoped credentialsHMAC token per call, 2 minutes, single useOAuth on-behalf-of or token exchange: a short-lived token for the user, with the agent recorded as the actor; narrow scopes, such as resource-specific permissions instead of tenant-wide ones
Per-tool permissionsRoles intersected with the agent's grantApp roles and groups for users; a separate, minimal permission set per agent identity
PDPOne decide() functionA policy engine such as Open Policy Agent or Cedar, with policies versioned and reviewed like code
ApprovalsFingerprinted, single-use, expiringAn approval workflow (Teams, ITSM or a privileged-access tool) showing the exact parameters
Audit logHash-chained listAppend-only storage plus your SIEM, with the user, agent, decision and approver on every event
SecretsA key only the gateway holdsA key vault and managed identity; nothing secret in prompts or model context

Secrets vs certificates vs tokens covers how the gateway itself should authenticate.

Checklist

  • The agent's identity has a minimal grant; tools the job doesn't need don't exist.
  • Every request is checked against the user's rights and the agent's grant, never the agent's alone.
  • One PDP holds the rules; tools don't decide for themselves.
  • Rules look at the resource (own vs anyone's, protected groups), not just the action.
  • Tools receive a per-call token bound to every parameter, short-lived and single-use, and they verify it themselves.
  • Privileged actions need approval from someone other than the requester, bound to the exact parameters, single-use and expiring.
  • Permissions are re-checked when the action runs, not just when it was requested.
  • Every decision, including denials, is logged with the user, agent, reason and approver, in append-only storage.
  • Authorization tests, especially the cases that should fail, run on every change.

The Short Version

PartWhat it doesWhat our tests showed
Per-tool permissionsEffective rights = the user's rights AND the agent's grantThe agent-identity shortcut would have allowed 13 of 15 non-allowed cases
PDPOne place answers "can the AI perform this action?"20 of 20 expected decisions; about 1 to 2 microseconds each
Scoped credentialsA token for one call, bound to every parameterReuse, another device, another group, expiry and tampering were all refused
ApprovalsA second person, for exactly these parameters, onceSelf-approval, changed parameters, reuse and expired approvals were all refused
Audit logEvery decision, hash-chainedAn edited entry was detected

Least privilege for an agent isn't one setting. It's a narrow grant, a decision made fresh for every call, a credential that can't be reused, and a record you can trust afterwards. Together that's about 170 lines, and it costs microseconds per call.

CChetan Yamger

Written by

Chetan Yamger

Cloud Engineer · AI Automation Architect · Modern Workplace Consultant

Cloud Engineer, AI Automation Architect, and Modern Workplace Consultant based in Amsterdam, Netherlands. Specializing in scalable, secure enterprise solutions with Microsoft Azure, Intune, PowerShell, and AI-driven automation using ChatGPT, Gemini, and modern LLM technologies.

Cloud & Modern WorkplaceMicrosoft Intune & MDMAzure & Microsoft 365AI AutomationPrompt EngineeringPowerShell & Graph APIWindows AutopilotConditional Access & Zero TrustSCCM / MECM & MSIXVDI / WVDPower BINode.js & Next.js
Newsletter

Stay in the loop.
New articles, straight to you.

Deep-dive technical articles on Intune, PowerShell, and AI — no noise, no spam.

New article notifications
No spam, ever
Free forever

Discussion

Share your thoughts — your email stays private

Leave a comment

0/2000

Your email is used to prevent spam and will never be displayed.