mastra-ai/mastra · error · HTTPException

Access denied: workflow run belongs to a different resource

Error message

Access denied: workflow run belongs to a different resource

What it means

validateRunOwnership throws this 403 when a workflow run's stored resourceId differs from the request's effective resource ID. It stops users from resuming, recovering, viewing, or deleting workflow runs that belong to a different resource, mirroring thread ownership checks for workflows.

Source

Thrown at packages/server/src/server/handlers/utils.ts:175

    mastra,
    user: user as { id: string; [key: string]: unknown },
    threadId,
    resourceId: thread?.resourceId ?? effectiveResourceId,
    requestContext,
    permission,
  });
}

/**
 * Validates that a workflow run belongs to the specified resourceId.
 * Throws 403 if the run exists but belongs to a different resource.
 */
export async function validateRunOwnership(
  run: { resourceId?: string | null } | null | undefined,
  effectiveResourceId: string | undefined,
): Promise<void> {
  if (run && effectiveResourceId && run.resourceId && run.resourceId !== effectiveResourceId) {
    throw new HTTPException(403, { message: 'Access denied: workflow run belongs to a different resource' });
  }
}

View on GitHub (pinned to 75dd419e61)

Solutions

  1. Resume/recover the run with the same resource ID (memory.resource or authenticated user) under which it was started.
  2. Look up the run's resourceId in storage and align the request's effective resource ID accordingly.
  3. Start a new workflow run under the current resource if cross-resource access was unintentional.
  4. Ensure mapUserToResourceId is stable across environments so the same user maps to the same resource ID.

Example fix

// before
await fetch(`/api/workflows/myWorkflow/runs/${runId}/resume`, { method: 'POST', body: JSON.stringify({ memory: { resource: 'user-2' }, ... }) }); // run started as user-1
// after
await fetch(`/api/workflows/myWorkflow/runs/${runId}/resume`, { method: 'POST', body: JSON.stringify({ memory: { resource: 'user-1' }, ... }) });
Defensive patterns

Strategy: try-catch

Validate before calling

async function assertRunOwnable(runId: string, resourceId: string, fetchRun: (id: string) => Promise<{ resourceId?: string | null } | null>) {
  const run = await fetchRun(runId);
  if (run?.resourceId && run.resourceId !== resourceId) {
    throw new Error(`Run ${runId} belongs to resource '${run.resourceId}', not '${resourceId}'`);
  }
}

Type guard

function runBelongsToResource(run: { resourceId?: string | null } | null, resourceId: string): boolean {
  return !!run && (!run.resourceId || run.resourceId === resourceId);
}

Try / catch

try {
  const res = await fetch(`/api/workflows/myWorkflow/runs/${runId}/resume`, { method: 'POST', body: JSON.stringify(payload) });
  if (res.status === 403) throw new Error(`Run ${runId} belongs to a different resource; resume with the originating resource ID`);
  return await res.json();
} catch (e) { throw e; }

Prevention

When it happens

Trigger: Calling RESUME_STREAM, RECOVER, resume-stream-until-idle, GET/DELETE workflow-run-by-id, or workflow resume-stream routes with a runId whose resourceId does not match the caller's effectiveResourceId (from body memory.resource or authenticated user).

Common situations: Sharing runIds across users/environments; resuming a run started under a different memory.resource or different mapped auth user; CI seeding runs with one resource ID while tests run as another; copying runIds from logs of another tenant.

Understand the failure class

Related errors


AI-assisted analysis of mastra-ai/mastra@75dd419e61 (2026-08-30). Data as JSON: /api/errors/dcfdd73ac356681b. Report an issue: GitHub.