Short answer: take user and tenant from your authenticated application session, identify the target as an application resource, and check that relationship inside the execution path. Treat the model’s tool arguments as untrusted input. A tenantId chosen by the model cannot establish tenancy.
For a write-capable agent, the useful authorization tuple is the principal, tenant, action, resource, and exact input. The model may propose the action and input. Your application must supply the principal and tenant.
The common shortcut trusts the wrong field
async function sendInvoice({ tenantId, invoiceId }) {
// tenantId came from the model's tool arguments
const invoice = await db.invoice.findFirst({
where: { id: invoiceId, tenantId },
})
return billing.send(invoice)
}
This can work in a single-tenant product or a local prototype where all data belongs to one trusted operator. It can also work when database row-level security derives the tenant from the database session and enforces the boundary end to end. In that case, keep the database control and test it.
The shortcut fails in a hosted SaaS application if changing one argument changes which tenant the tool queries. Tool schemas validate shape. They do not prove that the signed-in user belongs to the requested tenant or may act on the requested invoice.
Bind trusted context before the side effect
Nominee passes the application identity and resource to an authorizer while planning. It checks the authorizer again after capability consumption, immediately before execution. A permission revoked during an approval wait therefore stops the call.
import { Nominee, allow } from 'nominee'
const nominee = new Nominee({
policy: {
rules: [allow('invoice.send')],
fallback: 'deny',
},
authorizer: ({ user, action, resource, tenant }) =>
applicationAuthz.can({ user, action, resource, tenant }),
})
const input = {
invoiceId: modelArgs.invoiceId,
message: modelArgs.message,
}
await nominee.run(
{
tool: 'invoice.send',
input,
user: session.userId,
tenant: session.tenantId,
resource: 'invoice:' + input.invoiceId,
},
() =>
billing.sendInvoice({
tenantId: session.tenantId,
invoiceId: input.invoiceId,
message: input.message,
}),
)
The side effect receives the tenant from the session again. It never reads a tenant identifier from modelArgs. The resource string gives your existing FGA, RBAC, or custom authorizer a stable object to check.
Keep policy and relationship authorization separate
- Policy handles rules such as “invoice sends are allowed, exports require review, deletes are denied.”
- Your authorizer decides whether this user can perform the action on this invoice inside this tenant.
Nominee acts as the enforcement point. It does not replace Auth0 FGA, OpenFGA, WorkOS FGA, OPA, database RLS, or your own permission service. Teams with one trusted tenant and a complete database boundary may not need it. Teams with several agent frameworks can use it to keep the execution check consistent.
The companion essay explains the composition in more detail: FGA and OPA decide; nominee executes.
Test the execution boundary
Run allow, ask, and deny against the published package.