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

  1. 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.
  2. Restore the user's active company membership (set company_memberships.status='active') so the active-member branch of the audience check passes.
  3. 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.
  4. 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 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


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)