paperclipai/paperclip · error · ToolGatewayHttpError
grant_audience_denied
grant_audience_denied
Error message
The acting user is not in this grant's audience
What it means
This ToolGatewayHttpError (HTTP 403) is thrown when an active organization grant exists for a connection, but the acting user is not part of that grant's audience: isConnectionGrantAudienceAllowed(members, actingUserId, isActiveMember) returned false. The audience is the list of user subjects in connection_grant_members (subjectType='user'); an empty audience only permits users who are not active company members, so a real user absent from the list is denied. It enforces that shared credentials can only be used by explicitly admitted users.
Solutions
- Add the acting user to the grant's audience by inserting a connection_grant_members row {companyId, grantId, subjectType:'user', subjectId: actingUserId}, or re-save the grant with the user selected in the audience picker.
- Restore the user's active company membership (set company_memberships.status='active') so the active-member branch of the audience check passes.
- If the grant audience was intentionally narrowed, have a listed user (or an agent running under a listed owner's responsibleUserId) make the call instead.
- Confirm the actingUserId being resolved by the session/run matches the intended user — stale or wrong identity context will not match the audience list.
Example fix
// before: user missing from audience
await db.insert(connectionGrantMembers).values({
companyId: connection.companyId,
grantId: grant.id,
subjectType: "user",
subjectId: actingUserId,
});
// after: user admitted to the org grant audience Defensive patterns
Strategy: validation
Validate before calling
const [isMember] = await db.select({ id: companyMemberships.id }).from(companyMemberships).where(and(eq(companyMemberships.companyId, companyId), eq(companyMemberships.principalType, 'user'), eq(companyMemberships.principalId, actingUserId), eq(companyMemberships.status, 'active'))).limit(1);
const audience = await db.select({ subjectId: connectionGrantMembers.subjectId }).from(connectionGrantMembers).where(and(eq(connectionGrantMembers.grantId, grantId), eq(connectionGrantMembers.subjectType, 'user')));
const allowed = audience.length === 0 ? !isMember : audience.some(m => m.subjectId === actingUserId);
if (!allowed) throw new Error(`User ${actingUserId} is not in grant ${grantId} audience.`); Type guard
function isInGrantAudience(audience: { subjectId: string }[], actingUserId: string | null, isActiveMember: boolean): boolean {
return audience.some((m) => m.subjectId === actingUserId) || audience.length === 0 && isActiveMember;
} Try / catch
try {
await callGovernedTool(session, connectionId, toolName, args);
} catch (e) {
if (e instanceof ToolGatewayHttpError && e.code === "grant_audience_denied") {
return { status: "audience_denied", grantId: e.details.grantId, remediation: "add user to grant audience or restore membership" };
}
throw e;
} Prevention
- When onboarding new company members, add them to the audiences of the org grants they need (or keep audience lists in sync with membership automatically).
- Keep user memberships active; treat lapsed membership as a first suspect when tool calls start failing.
- Run the audience check (mirrored above) in CI or a preflight step for agent run configurations that name a connection.
- Avoid empty audiences on org grants unless 'non-members only' is the intended, documented semantics.
When it happens
Trigger: A user (actingUserId) invokes a governed tool for an organization-granted connection while (a) their userId is not present in connection_grant_members for that grant, or (b) they are not an active company member and the grant audience list does not include them — either way isConnectionGrantAudienceAllowed returns false.
Common situations: A new teammate joins the company after the org grant was created and is never added to the grant audience; an admin intentionally restricts the grant audience and a non-listed user's agent triggers the call; the acting user's company membership lapsed (status not 'active') so they no longer count as the active-member escape hatch.
Understand the failure class
Background: "You do not have permission" / 403 Forbidden errors: authenticated but not allowed — causes and fixes across open-source libraries — this error's family across 31 libraries.
Related errors
- organization_authorization_required
- ambiguous_personal_grant
- Discord command registration authorization denied
- github_identity_unavailable
- grant_owner_membership_inactive
AI-assisted analysis of paperclipai/paperclip@3f1d897a7c (2026-09-18).
Data as JSON: /api/errors/fcc646a6aef3de8d.
Report an issue: GitHub.
Appendix: source
Thrown at server/src/services/tool-gateway.ts:4464
.where(
and(
eq(companyMemberships.companyId, connection.companyId),
eq(companyMemberships.principalType, "user"),
eq(companyMemberships.principalId, actingUserId),
eq(companyMemberships.status, "active"),
),
)
.limit(1)
.then((rows) => rows[0] ?? null)
: null;
if (
!isConnectionGrantAudienceAllowed(
members.map((member) => member.subjectId),
actingUserId,
Boolean(activeAudienceMember),
)
) {
throw new ToolGatewayHttpError(
403,
"The acting user is not in this grant's audience",
"grant_audience_denied",
{
connectionId: connection.id,
grantId: grant.id,
actingUserId,
},
);
}
return grant;
};
if (connection.credentialPolicy === "per_agent") {
if (!session.agentId) {
throw new ToolGatewayHttpError(
409,
"A dedicated agent authorization is required",View on GitHub (pinned to 3f1d897a7c)