Your AI Agent Doesn't Need a Service Account: Least-Privilege Agent Identity in Django (Part 2)
Scoped, short-lived, on-behalf-of grants per agent run, checked against the human's own Django permissions on every tool call.

Short version
- Stop running agents as a service account. It lets the agent do things the person who asked can't, and your audit log can't say who it worked for.
- Give each agent run its own grant: one registered agent, one human it acts for, a few scopes, a 15-minute expiry, and a correlation ID.
- Check twice. A tool runs only if the scope is in the grant and the human still holds the matching Django permission at the moment of the call.
- Destructive tools don't execute, they queue for a person with the right permission (that's Part 1).
- Prove it with pytest. 18 tests show the agent can't escalate, can't outlive a removed permission, and leaves an append-only trail.
Here's how a lot of AI agents get to production. Someone creates an ai-bot user, makes it staff ("just for now"), puts its API key in an environment variable, and points the agent at it. Every user who chats with the agent now borrows that account. The agent can read every row and edit every record, and the audit log says ai-bot did all of it.
This week brought four announcements built on the opposite assumption. On October 28, OpenAI and ServiceNow announced a ChatGPT integration where ServiceNow's Action Fabric "applies existing business rules and permissions, so the actions people can take reflect their organization's configuration and access" (OpenAI). On October 29, SentinelOne launched Agentic Detection and Response, with an inventory of agents and MCP servers "with the permissions each holds and the employees behind it" and "approval workflows for high-risk tools" (CXOToday). Neo shipped tamper protection so users and agents can't remove the security hooks it installs in coding agents (GlobeNewswire), and Cisco open-sourced Project Quarry to catch attacks spread across harmless-looking agent steps (SecurityWeek).
And agents are doing more of the work: GitHub's Octoverse 2026 says more than 40% of pull requests in September 2026 started with an agent's first draft, up from 17% in March (GitHub Blog).
You don't need any of those products to get the core idea right. This is Part 2 of Shipping AI Agents in Django. Part 1 covered approval gates: the model proposes, a human decides. This part is about identity: who the agent is, who it's working for, and what it may touch while it does.
The rule this post is built on
My opinion, stated plainly: if your agent can do something the user who asked it can't, that's a bug, not a feature.
NIST, AWS and Microsoft agree. NIST's new design tenets for agent identity say that as authority is delegated, "scopes attenuate so that downstream entities can never claim more authority than the parent" (NIST, Oct 27, 2026). The AWS Well-Architected Agentic AI Lens says an agent acting for a user must be "bounded by that user's permissions", and flags "shared API keys or static tokens left in use for agent authentication" as a common issue (AWS). Microsoft's least-privilege guidance asks for a dedicated agent identity with a named owner, logs that record "agent identity, role, effective scope, action, resource, correlation ID, and 'on behalf of' user", and tested revocation paths (Microsoft Learn).
Service account vs. scoped agent identity
| Shared service account | Scoped agent identity | |
|---|---|---|
| Audit log names | ai-bot |
billing-assistant for user 42, run 9f1c… |
| What it can do | Everything the account can, for every user | Agent ceiling ∩ requested scopes ∩ the user's own permissions |
| Row-level access | Usually all rows | The same querysets the user sees |
| Lifetime | Until someone rotates the key | 15 minutes per run |
| User loses a permission | No effect on the agent | Next tool call is denied |
| Destructive actions | Run immediately | Queued for a human with the right permission |
| Shutting it down | Rotate a key used in many places | One call revokes every live grant |
The example app
A small billing app. An Invoice has an account manager; account managers see their own invoices, and finance staff with view_all_invoices see everything. That row-level rule matters: an agent acting for an account manager should see exactly what that person sees.
from django.conf import settings
from django.db import models
class InvoiceQuerySet(models.QuerySet):
def visible_to(self, user):
# Row-level rule: account managers see their own invoices,
# finance staff with view_all_invoices see everything.
if user.has_perm("billing.view_all_invoices"):
return self
return self.filter(account_manager=user)
class Invoice(models.Model):
number = models.CharField(max_length=32, unique=True)
customer = models.CharField(max_length=200)
amount = models.DecimalField(max_digits=12, decimal_places=2)
status = models.CharField(max_length=16, default="open") # open | void
notes = models.TextField(blank=True)
account_manager = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name="+"
)
objects = InvoiceQuerySet.as_manager()
class Meta:
permissions = [
("view_all_invoices", "Can view every invoice"),
("void_invoice", "Can void an invoice"),
]
The "billing assistant" agent gets three tools:
list_open_invoices: read, scopeinvoices:read, permissionbilling.view_invoice.add_invoice_note: write, scopeinvoices:annotate, permissionbilling.change_invoice.void_invoice: destructive, scopeinvoices:void, permissionbilling.void_invoice, and never runs without a human approving it.
The demo is a plain Django 5.2 project (apps agents and billing, SQLite). Every snippet below is copied from it, and the tests pass against it.
Step 1: Model the agent, the grant and the audit trail
AgentIdentity is the long-lived registration. AgentGrant is the badge for one run. AuditEvent is the logbook. PendingAction is Part 1's approval queue, cut to the minimum.
import hashlib
import uuid
from django.conf import settings
from django.db import models
from django.utils import timezone
class AgentIdentity(models.Model):
"""A registered agent: what it is, who owns it, which scopes it may ever ask for."""
slug = models.SlugField(unique=True) # e.g. "billing-assistant"
owner = models.ForeignKey( # the accountable human
settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name="owned_agents"
)
purpose = models.TextField()
allowed_scopes = models.JSONField(default=list) # ceiling, e.g. ["invoices:read"]
is_active = models.BooleanField(default=True) # the kill switch
created_at = models.DateTimeField(auto_now_add=True)
def hash_token(raw: str) -> str:
return hashlib.sha256(raw.encode()).hexdigest()
class AgentGrant(models.Model):
"""One run's credential: this agent, for this user, these scopes, until this time."""
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
agent = models.ForeignKey(AgentIdentity, on_delete=models.PROTECT, related_name="grants")
on_behalf_of = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name="agent_grants"
)
scopes = models.JSONField(default=list)
correlation_id = models.UUIDField(default=uuid.uuid4, db_index=True)
token_hash = models.CharField(max_length=64, unique=True)
expires_at = models.DateTimeField()
revoked_at = models.DateTimeField(null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
def is_usable(self) -> bool:
return (
self.revoked_at is None
and self.expires_at > timezone.now()
and self.agent.is_active
and self.on_behalf_of.is_active
)
class AuditEvent(models.Model):
"""Append-only record of every tool decision."""
created_at = models.DateTimeField(auto_now_add=True, db_index=True)
agent_slug = models.CharField(max_length=50)
grant_id = models.UUIDField(null=True)
on_behalf_of_id = models.IntegerField(null=True)
correlation_id = models.UUIDField(null=True, db_index=True)
tool = models.CharField(max_length=100)
decision = models.CharField(max_length=20) # allowed | denied | pending | approved | rejected
reason = models.CharField(max_length=200, blank=True)
args = models.JSONField(default=dict)
def save(self, *args, **kwargs):
if self.pk is not None:
raise RuntimeError("AuditEvent is append-only")
super().save(*args, **kwargs)
def delete(self, *args, **kwargs):
raise RuntimeError("AuditEvent is append-only")
class PendingAction(models.Model):
"""A destructive tool call waiting for a human (Part 1 has the full pattern)."""
grant = models.ForeignKey(AgentGrant, on_delete=models.PROTECT, related_name="pending")
tool = models.CharField(max_length=100)
args = models.JSONField(default=dict)
status = models.CharField(max_length=16, default="pending") # pending | approved | rejected
decided_by = models.ForeignKey(
settings.AUTH_USER_MODEL, null=True, blank=True, on_delete=models.PROTECT, related_name="+"
)
decided_at = models.DateTimeField(null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
Decisions worth explaining:
owneris a real human, so someone's name is on the agent.allowed_scopesis a ceiling, not a grant. Registering the agent withinvoices:voiddoesn't let anyone void through it. It means a run may ask for that scope if the human has it.- Only a token hash is stored, so a database dump leaks no live credentials.
is_usable()checks four things: not revoked, not expired, agent active, user active. Deactivating a person kills their agent runs too.AuditEventrefuses updates and deletes. That's only an app-level guard; NIST's tenet 10 wants logging "independent of the agent" (see trade-offs).
Step 2: Issue a short-lived grant per run
When a user starts a task, your view asks for a grant. The grant is the intersection of what was requested, what the agent is registered for, and what the user can do. If a requested scope fails either check, issuing fails loudly instead of silently dropping it. Otherwise someone eventually widens permissions to make a mystery error go away, which AWS names as a cause of privilege creep.
import secrets
from datetime import timedelta
from django.conf import settings
from django.db import transaction
from django.utils import timezone
from .models import AgentGrant, AgentIdentity, hash_token
class GrantError(Exception):
pass
# Which Django permission backs each scope. A scope the user can't
# exercise themselves can never be granted to an agent acting for them.
SCOPE_PERMISSIONS = {
"invoices:read": "billing.view_invoice",
"invoices:annotate": "billing.change_invoice",
"invoices:void": "billing.void_invoice",
}
def issue_grant(*, agent: AgentIdentity, user, scopes: list[str], ttl_seconds=None):
"""Mint a short-lived, scoped credential for one agent run.
Returns (grant, raw_token). Only the hash is stored; the raw token
is handed to the agent runtime and never written to the database.
"""
if not agent.is_active:
raise GrantError(f"agent {agent.slug} is disabled")
if not user.is_active:
raise GrantError("user is inactive")
requested = set(scopes)
unknown = requested - SCOPE_PERMISSIONS.keys()
if unknown:
raise GrantError(f"unknown scopes: {sorted(unknown)}")
over_agent = requested - set(agent.allowed_scopes)
if over_agent:
raise GrantError(f"agent {agent.slug} may not request {sorted(over_agent)}")
over_user = {s for s in requested if not user.has_perm(SCOPE_PERMISSIONS[s])}
if over_user:
raise GrantError(f"user lacks permission for {sorted(over_user)}")
ttl = ttl_seconds or getattr(settings, "AGENT_GRANT_TTL_SECONDS", 900)
raw = secrets.token_urlsafe(32)
grant = AgentGrant.objects.create(
agent=agent,
on_behalf_of=user,
scopes=sorted(requested),
token_hash=hash_token(raw),
expires_at=timezone.now() + timedelta(seconds=ttl),
)
return grant, raw
def resolve_grant(raw_token: str) -> AgentGrant | None:
grant = (
AgentGrant.objects.select_related("agent", "on_behalf_of")
.filter(token_hash=hash_token(raw_token))
.first()
)
if grant is None or not grant.is_usable():
return None
return grant
def revoke_grant(grant: AgentGrant) -> None:
AgentGrant.objects.filter(pk=grant.pk, revoked_at__isnull=True).update(
revoked_at=timezone.now()
)
@transaction.atomic
def disable_agent(agent: AgentIdentity) -> int:
"""Kill switch: stop the agent and revoke every live grant it holds."""
AgentIdentity.objects.filter(pk=agent.pk).update(is_active=False)
return AgentGrant.objects.filter(agent=agent, revoked_at__isnull=True).update(
revoked_at=timezone.now()
)
SCOPE_PERMISSIONS is a plain dict on purpose: changing what a scope means shows up in code review.
In a view, call issue_grant(agent=agent, user=request.user, scopes=[...]), hand the raw token to the agent runtime for that run only, and put grant.correlation_id in your tracing span and logs.
Fifteen minutes is a default, not a law; Ping Identity's agent-token guide uses 5 minutes (Ping Identity). Use the shortest TTL your runs fit in, and issue new grants rather than extending old ones.
Step 3: One door for every tool call
The model never calls your functions. It emits a tool name and arguments, and your runtime hands them to call_tool with the grant token. That's where policy lives. NIST's tenet 07 puts it as separating "the logic that determines what an agent attempts to do" from "the systems that determine what it is allowed to do." Whatever a prompt injection convinces the model to try, the decision happens in code the model can't edit.
from dataclasses import dataclass
from functools import wraps
from typing import Callable
from django.db import transaction
from django.utils import timezone
from .grants import resolve_grant
from .models import AgentGrant, AuditEvent, PendingAction
READ, WRITE, DESTRUCTIVE = "read", "write", "destructive"
class ToolDenied(Exception):
pass
@dataclass(frozen=True)
class Tool:
name: str
scope: str
permission: str
kind: str
func: Callable
REGISTRY: dict[str, Tool] = {}
def agent_tool(*, scope: str, permission: str, kind: str = READ):
"""Register a function as an agent tool with its scope and Django permission."""
def decorator(func):
REGISTRY[func.__name__] = Tool(func.__name__, scope, permission, kind, func)
@wraps(func)
def direct_call(*args, **kwargs):
raise RuntimeError("Agent tools run through call_tool(), not directly")
return direct_call
return decorator
def _audit(grant, tool_name, decision, reason="", args=None, agent_slug=None):
AuditEvent.objects.create(
agent_slug=agent_slug or (grant.agent.slug if grant else "unknown"),
grant_id=grant.pk if grant else None,
on_behalf_of_id=grant.on_behalf_of_id if grant else None,
correlation_id=grant.correlation_id if grant else None,
tool=tool_name,
decision=decision,
reason=reason,
args=args or {},
)
def _deny(grant, tool_name, reason, args):
_audit(grant, tool_name, "denied", reason, args)
raise ToolDenied(reason)
def call_tool(raw_token: str, tool_name: str, args: dict):
"""The only door from the agent runtime into your app."""
grant = resolve_grant(raw_token)
if grant is None:
_audit(None, tool_name, "denied", "invalid, expired or revoked grant", args)
raise ToolDenied("invalid, expired or revoked grant")
tool = REGISTRY.get(tool_name)
if tool is None:
_deny(grant, tool_name, "unknown tool", args)
if tool.scope not in grant.scopes:
_deny(grant, tool_name, f"scope {tool.scope} not in grant", args)
user = grant.on_behalf_of
# Re-check the human's permission on every call: permissions can be
# removed mid-run, and the grant must never outlive them.
user = type(user).objects.get(pk=user.pk) # fresh copy, no stale perm cache
if not user.has_perm(tool.permission):
_deny(grant, tool_name, f"user lacks {tool.permission}", args)
if tool.kind == DESTRUCTIVE:
pending = PendingAction.objects.create(grant=grant, tool=tool_name, args=args)
_audit(grant, tool_name, "pending", f"approval #{pending.pk} required", args)
return {"status": "pending_approval", "pending_id": pending.pk}
result = tool.func(user=user, **args)
_audit(grant, tool_name, "allowed", "", args)
return result
@transaction.atomic
def decide(pending_id: int, *, approver, approve: bool):
"""A human approves or rejects a destructive call."""
pending = (
PendingAction.objects.select_for_update()
.select_related("grant__agent", "grant__on_behalf_of")
.get(pk=pending_id)
)
grant: AgentGrant = pending.grant
tool = REGISTRY[pending.tool]
if pending.status != "pending":
raise ToolDenied("already decided")
if not grant.agent.is_active or grant.revoked_at is not None:
raise ToolDenied("grant revoked or agent disabled since the request")
if not approver.has_perm(tool.permission):
raise ToolDenied("approver lacks permission for this action")
# The requesting user must still hold the permission too.
requester = type(grant.on_behalf_of).objects.get(pk=grant.on_behalf_of_id)
if not requester.has_perm(tool.permission):
raise ToolDenied("requesting user no longer holds this permission")
pending.status = "approved" if approve else "rejected"
pending.decided_by = approver
pending.decided_at = timezone.now()
pending.save()
if not approve:
_audit(grant, pending.tool, "rejected", f"by user {approver.pk}", pending.args)
return {"status": "rejected"}
result = tool.func(user=requester, **pending.args)
_audit(grant, pending.tool, "approved", f"by user {approver.pk}", pending.args)
return result
The checks run in order: grant is live, tool exists, scope is in the grant, and the human holds the Django permission now (we re-fetch the user because Django caches permissions on the instance). Destructive tools then stop and create a PendingAction; everything else runs with the human as user, so your normal querysets apply. Every outcome, including dead tokens and hallucinated tool names, is audited.
The permission re-check is the one people skip. Scopes are fixed when the run starts, but someone can lose their finance role at 10:02, and the run that started at 10:00 shouldn't still be acting with it at 10:05.
The decorator also makes direct calls raise, so nobody can import void_invoice into a view and bypass all this.
Step 4: Separate read, write and destructive tools
Each tool takes user and goes through Invoice.objects.visible_to(user). That's the row-level half of "never more than the user". Import the module in BillingConfig.ready() so the registry fills at startup.
from agents.tools import DESTRUCTIVE, READ, WRITE, agent_tool
from .models import Invoice
@agent_tool(scope="invoices:read", permission="billing.view_invoice", kind=READ)
def list_open_invoices(*, user, customer: str = ""):
qs = Invoice.objects.visible_to(user).filter(status="open")
if customer:
qs = qs.filter(customer__icontains=customer)
return [
{"number": i.number, "customer": i.customer, "amount": str(i.amount)}
for i in qs.order_by("number")[:50]
]
@agent_tool(scope="invoices:annotate", permission="billing.change_invoice", kind=WRITE)
def add_invoice_note(*, user, number: str, note: str):
invoice = Invoice.objects.visible_to(user).get(number=number)
invoice.notes = (invoice.notes + "\n" + note[:500]).strip()
invoice.save(update_fields=["notes"])
return {"number": invoice.number, "notes": invoice.notes}
@agent_tool(scope="invoices:void", permission="billing.void_invoice", kind=DESTRUCTIVE)
def void_invoice(*, user, number: str):
invoice = Invoice.objects.visible_to(user).get(number=number, status="open")
invoice.status = "void"
invoice.save(update_fields=["status"])
return {"number": invoice.number, "status": invoice.status}
Why three kinds? "Write" covers very different risks. A note is reversible, so it just runs. A void affects money and customers, so even a fully authorized user's agent waits for a person. Microsoft's guidance draws a similar line, blocking delete and admin actions and requiring approval for bulk updates.
My rule of thumb: a tool is destructive if undoing it means a human apologising to someone. Voids, refunds, deletes, customer emails, permission changes.
Keep tool arguments narrow, too. add_invoice_note appends a note capped at 500 characters. A generic update_invoice(**fields) tool is a service account in disguise.
Step 5: Re-check at approval time
decide() above is the approval side. It locks the row so two approvals can't run the action twice, refuses if the grant was revoked while waiting, requires the approver to hold the permission, and requires the requester to still hold it, then runs the tool as the requester.
In a real app, merge this with Part 1's Proposal flow: Part 1 covers what changes, this post who asks and whether they may.
NIST's tenet 05 warns about "consent fatigue", where escalations are so frequent that actions get "approved habitually." If reviewers approve 200 voids a day, narrow what counts as destructive.
Step 6: Revocation and the kill switch
revoke_grant() ends one run. disable_agent() deactivates the agent and revokes every live grant in one transaction. Because call_tool resolves the grant on every call, the next call from any run fails. No waiting for tokens to expire. Microsoft lists "slow revocation and incomplete containment" among its agent security challenges, so put disable_agent behind a big red button in your admin, and test it.
Step 7: Prove there's no privilege escalation
The fixtures (not shown) create the agent, a clerk who can view and annotate but not void, a manager who can void, and one invoice each. Here are the tests I'd show a reviewer first (the full suite has 18, also covering permission removal mid-run, expiry, revocation, the kill switch, approvals and the append-only log):
def test_grant_cannot_include_scope_the_user_lacks(agent, clerk):
with pytest.raises(GrantError, match="user lacks permission"):
issue_grant(agent=agent, user=clerk, scopes=["invoices:read", "invoices:void"])
def test_tampered_grant_still_cannot_exceed_user(agent, clerk, invoices):
# Someone edits the grant row directly (bug, bad migration, SQL console).
grant, token = issue_grant(agent=agent, user=clerk, scopes=["invoices:read"])
AgentGrant.objects.filter(pk=grant.pk).update(scopes=["invoices:read", "invoices:void"])
with pytest.raises(ToolDenied, match="user lacks billing.void_invoice"):
call_tool(token, "void_invoice", {"number": "INV-1"})
assert Invoice.objects.get(number="INV-1").status == "open"
def test_every_decision_is_audited_with_agent_user_and_correlation(agent, clerk, invoices):
grant, token = issue_grant(agent=agent, user=clerk, scopes=["invoices:read"])
call_tool(token, "list_open_invoices", {})
with pytest.raises(ToolDenied):
call_tool(token, "add_invoice_note", {"number": "INV-1", "note": "x"})
events = list(AuditEvent.objects.filter(correlation_id=grant.correlation_id).order_by("pk"))
assert [(e.tool, e.decision) for e in events] == [
("list_open_invoices", "allowed"),
("add_invoice_note", "denied"),
]
assert all(e.agent_slug == "billing-assistant" for e in events)
assert all(e.on_behalf_of_id == clerk.pk for e in events)
test_tampered_grant_still_cannot_exceed_user is the important one. It simulates a bad day: someone edits a grant row directly and adds invoices:void. The tool is still denied, because the clerk doesn't hold billing.void_invoice and call_tool checks the human on every call.
$ pytest -q
.................. [100%]
18 passed in 0.24s
Run this next to the evals from our previous post.
Optional: calling an external API with RFC 8693
Once the agent calls another service, you need to carry "agent X, acting for user Y, with scopes Z" across the network, without forwarding the user's session or giving the agent its own all-powerful key. That's OAuth 2.0 Token Exchange, RFC 8693.
Your backend posts to the authorization server with grant_type=urn:ietf:params:oauth:grant-type:token-exchange, a subject_token proving the user and an actor_token proving the agent. Send the grant's scopes, not the agent's ceiling, as scope. It gets back a token for one audience whose sub is the user and whose act claim names the agent. Ping Identity's guide shows one: "sub": "alice@example.com", "act": {"sub": "expense-agent"}, a narrow scope, 5-minute expiry. On the receiving side:
def check_delegated_claims(claims, *, expected_agent, required_scope):
"""Run on the receiving API AFTER signature, iss, aud and exp are verified.
Per RFC 8693 section 4.1, only top-level claims and the current actor
(the outermost "act") count for access control; nested "act" entries
are informational history.
"""
actor = (claims.get("act") or {}).get("sub")
if actor != expected_agent:
raise PermissionError(f"unexpected actor {actor!r}")
if required_scope not in claims.get("scope", "").split():
raise PermissionError(f"missing scope {required_scope}")
return {"user": claims["sub"], "agent": actor}
Two details that are easy to miss:
- Only the current actor counts. Section 4.1 says consumers "MUST only consider the token's top-level claims and the party identified as the current actor by the act claim"; nested actors "are informational only." If a planner agent hands off to a billing agent, the API authorizes the billing agent.
- This isn't verification.
check_delegated_claimsruns after a JWT library has verified signature,iss,audandexpagainst the issuer's keys.
I haven't tested this against a live authorization server; the demo's two delegation tests cover request building and claim checks only, so pair it with your provider's docs.
Trade-offs
More moving parts. A service account is one row and an environment variable; this is four models, a registry and a policy function. Microsoft's own trade-offs list "increased upfront design effort." My opinion: for anything touching money, health records or customer data, that complexity is the point.
Queries per call. Fine at chat speed. At hundreds of calls per run, cache permissions briefly and document that revocation becomes "within N seconds".
Scopes are coarse. invoices:annotate covers every invoice the user can see. Per-resource grants, or NIST's signed "mandates", are the next step.
Agents with no human. Scheduled or event-driven agents have no on_behalf_of. AWS treats these separately. Don't invent a fake human: give the agent its own narrow group and a nullable on_behalf_of, so the log says "autonomous run".
App-level append-only isn't tamper-proof. It stops mistakes, not someone with database access. Stream events elsewhere, or revoke UPDATE/DELETE on the table from the app's DB role.
Allowed actions can still be misused. An injected agent can still write junk notes on every invoice the user can annotate. Per-grant rate limits and volume alerts are the cheap defence.
Checklist you can paste into your PR template
### Agent identity checklist
- [ ] Agent has a named human owner and purpose
- [ ] New tool declares scope, Django permission and kind (read / write / destructive)
- [ ] Tool queries use the same row-level querysets as the user's views
- [ ] Destructive tools go through approval; approver permission is checked
- [ ] Grants are per run, expire in <= 15 minutes, never extended
- [ ] Every allow / deny / pending / approve / reject writes an AuditEvent
with agent, on-behalf-of user and correlation ID
- [ ] Kill switch is tested and reachable from the admin
- [ ] Tests: allowed for a permitted user, denied without the permission
- [ ] External API calls use token exchange with the grant's scopes
Where this leaves you
The service account feels simpler because it hides the hard question: who is this agent working for, and what are they allowed to do? An injected prompt can make the model try to void an invoice. It can't put a permission into a clerk's grant that the clerk doesn't have, and it can't skip the person who approves voids.
Start with one test: write test_tampered_grant_still_cannot_exceed_user against your current agent setup and see whether it passes.
Shipping AI Agents in Django
- Part 1: Human-in-the-loop approval gates, audit trails and evals
- Part 2: Least-privilege agent identity (this post)
- Coming next, Part 3: an MCP server for your Django app, exposing these scoped tools to any MCP client
If you're wiring agents into a Django or Python product and want a second pair of eyes on the permission model, that's the kind of work we do at Lycore.
About the author
- Custom software & AI development company. We build production-ready AI integrations, mobile apps, and web platforms for businesses that want real results. lycore.com
Comments
Join the discussionAvoiding a single ai-bot service account is the decision that makes everything else in the post work. Once an agent has its own all powerful user, every audit entry says the bot did it, and nobody can tell which person's request caused which change.
The grant as an intersection of the agent ceiling, the requested scopes and the user's own permissions is a clean rule. The agent can never do more than the person who asked, and never more than it was built for, even if the prompt asks nicely.
Rechecking the Django permission on every call instead of trusting the grant at issue time is the detail I would point others to. Permissions change during a fifteen minute window, and a revoked user should not keep acting through an agent.
The PendingAction queue for destructive tools and the tampered grant test are both worth copying directly. Did consent fatigue show up in practice, with users approving queued actions without reading them?
Thanks. I can't point to usage data for this, so treat it as a design suggestion: approval fatigue is a well-known problem with confirmation prompts in general. Common mitigations are to queue only truly destructive or irreversible actions, show a concrete diff of what will change rather than a generic "approve?", and sample-audit approvals to spot rubber-stamping. Measuring approval latency per action would be a cheap early signal.



