Enforce trusted plugin bridge user scope

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-05-24 02:33:49 +00:00
1 parent 7938babf19
commit 8320e7e172
13 files changed
+327 -76

No files matched your search

+9 -1
View File
@@ -607,7 +607,11 @@ The plugin UI calls the host bridge, which forwards the request to the worker. T
Input includes:
- data key (plugin-defined, e.g. `"sync-health"`, `"issue-detail"`)
- context (company id, project id, entity id, etc.)
- browser-supplied context (company id, project id, entity id, etc.)
- trusted actor context from the host, available to SDK handlers as the second
argument (`request.actor`). User-scoped plugins must compare any requested
`userId` with `request.actor.userId`; they must not trust a `userId` supplied
by the plugin UI request body.
- optional query parameters
### 13.9 `performAction`
@@ -621,6 +625,10 @@ Examples:
- "create branch from issue"
- "restart process"
Action handlers receive the same trusted host request context as data handlers
as their second argument. User-scoped mutations must authorize against
`request.actor.userId` rather than any caller-supplied `params.userId`.
### 13.10 `executeTool`
Runs a plugin-contributed agent tool during a run.
+21 -5
View File
@@ -2,6 +2,7 @@ import {
definePlugin,
runWorker,
type Issue,
type PluginBridgeRequestContext,
type PluginContext,
type PluginIssueRelationSummary,
type ToolResult,
@@ -51,6 +52,16 @@ function stringParam(value: unknown, name: string): string {
return value.trim();
}
function assertTrustedBridgeUserScope(userId: string, request?: PluginBridgeRequestContext): void {
const actorUserId = request?.actor?.userId;
if (!actorUserId) {
throw new Error("Briefs user-scoped UI calls require a signed-in user");
}
if (actorUserId !== userId) {
throw new Error("Briefs user scope mismatch");
}
}
function numberParam(value: unknown): number | null {
return typeof value === "number" && Number.isFinite(value) ? Math.max(0, Math.trunc(value)) : null;
}
@@ -280,26 +291,28 @@ const plugin = definePlugin({
async setup(ctx) {
const store = createBriefsStore(ctx.db);
ctx.data.register("cards", async (params) => {
ctx.data.register("cards", async (params, request) => {
const input = listBriefCardsInputSchema.parse(params);
assertTrustedBridgeUserScope(input.userId, request);
return {
cards: await store.listCards(input),
};
});
ctx.data.register("preferences", async (params) => {
ctx.data.register("preferences", async (params, request) => {
const input = objectParam(params, "params");
const companyId = typeof input.companyId === "string" ? input.companyId : "";
const userId = typeof input.userId === "string" ? input.userId : "";
if (!companyId || !userId) {
throw new Error("companyId and userId are required");
}
assertTrustedBridgeUserScope(userId, request);
return {
preferences: await store.loadPreferences({ companyId, userId }),
};
});
ctx.data.register("page", async (params) => {
ctx.data.register("page", async (params, request) => {
const input = objectParam(params, "params");
const companyId = typeof input.companyId === "string" ? input.companyId : "";
const userId = typeof input.userId === "string" ? input.userId : "";
@@ -307,6 +320,7 @@ const plugin = definePlugin({
if (!companyId || !userId) {
throw new Error("companyId and userId are required");
}
assertTrustedBridgeUserScope(userId, request);
const [cards, preferences] = await Promise.all([
store.listCards({ companyId, userId, includeHidden }),
store.loadPreferences({ companyId, userId }),
@@ -373,8 +387,9 @@ const plugin = definePlugin({
return ctx.routines.managed.run(MANUAL_REFRESH_ROUTINE_KEY, stringParam(params.companyId, "companyId"));
});
ctx.actions.register("pin-card", async (params) => {
ctx.actions.register("pin-card", async (params, request) => {
const input = pinBriefCardInputSchema.parse(params);
assertTrustedBridgeUserScope(input.userId, request);
await store.setPinned(input);
await ctx.activity.log({
companyId: input.companyId,
@@ -386,8 +401,9 @@ const plugin = definePlugin({
return { ok: true };
});
ctx.actions.register("update-preferences", async (params) => {
ctx.actions.register("update-preferences", async (params, request) => {
const input = updateBriefPreferencesInputSchema.parse(params);
assertTrustedBridgeUserScope(input.userId, request);
const preferences = briefPreferencesSchema.parse(input);
await store.upsertPreferences(preferences);
await ctx.activity.log({
@@ -11,6 +11,14 @@ import manifest, {
import plugin from "../src/worker.js";
const companyId = "11111111-1111-4111-8111-111111111111";
const bridgeActor = {
actorType: "user" as const,
actorId: "signed-in-user",
userId: "signed-in-user",
agentId: null,
runId: null,
source: "session",
};
describe("Briefs managed resources", () => {
it("declares the Briefing Analyst, skills, routines, and agent tools", () => {
@@ -71,4 +79,23 @@ describe("Briefs managed resources", () => {
expect(routine.missingRefs).toEqual([]);
}
});
it("rejects user-scoped UI bridge calls for a different user", async () => {
const harness = createTestHarness({ manifest });
await plugin.definition.setup?.(harness.ctx);
const victimParams = { companyId, userId: "victim-user" };
const context = { actor: bridgeActor };
await expect(harness.getData("page", victimParams, context)).rejects.toThrow("Briefs user scope mismatch");
await expect(harness.getData("preferences", victimParams, context)).rejects.toThrow("Briefs user scope mismatch");
await expect(harness.performAction("pin-card", {
...victimParams,
cardId: "card-1",
pinned: true,
}, context)).rejects.toThrow("Briefs user scope mismatch");
await expect(harness.performAction("update-preferences", {
...victimParams,
cadence: "daily",
}, context)).rejects.toThrow("Briefs user scope mismatch");
});
});
+4
View File
@@ -564,6 +564,8 @@ export function DashboardWidget() {
Fetches data from the worker's registered `getData` handler. Re-fetches when `params` changes. Returns `{ data, loading, error, refresh }`.
Worker handlers receive browser-supplied `params` as the first argument and trusted host context as the second argument. For user-scoped data, compare any requested `params.userId` with `request.actor.userId`; do not trust `params.userId` by itself.
```tsx
import { usePluginData } from "@paperclipai/plugin-sdk/ui";
@@ -596,6 +598,8 @@ export function SyncStatusWidget({ context }: PluginWidgetProps) {
Returns an async function that calls the worker's `performAction` handler. Throws `PluginBridgeError` on failure.
Action handlers also receive trusted host context as their second argument. User-scoped mutations should authorize against `request.actor.userId`.
```tsx
import { useState } from "react";
import { usePluginAction, type PluginBridgeError } from "@paperclipai/plugin-sdk/ui";
+4
View File
@@ -241,6 +241,10 @@ export type {
AgentSessionEvent,
AgentSessionSendResult,
PluginGoalsClient,
PluginBridgeActorContext,
PluginBridgeRequestContext,
PluginDataHandler,
PluginActionHandler,
PluginDataClient,
PluginActionsClient,
PluginStreamsClient,
+5
View File
@@ -65,6 +65,7 @@ import type {
PluginAuthorizationDecisionResult,
PluginAuthorizationPolicyRecord,
PluginAuthorizationPolicySummary,
PluginBridgeActorContext,
} from "./types.js";
import type {
PluginHealthDiagnostics,
@@ -361,6 +362,8 @@ export interface GetDataParams {
companyId?: string | null;
/** Context and query parameters from the UI. */
params: Record<string, unknown>;
/** Trusted actor context supplied by the host, never by the browser. */
actor?: PluginBridgeActorContext | null;
/** Optional launcher/container metadata from the host render environment. */
renderEnvironment?: PluginLauncherRenderContextSnapshot | null;
}
@@ -401,6 +404,8 @@ export interface PerformActionParams {
params: Record<string, unknown>;
/** Authenticated actor context resolved by the host, never by caller params. */
actorContext?: PluginPerformActionActorContext | null;
/** Trusted actor context supplied by the host, never by the browser. */
actor?: PluginBridgeActorContext | null;
/** Optional launcher/container metadata from the host render environment. */
renderEnvironment?: PluginLauncherRenderContextSnapshot | null;
}
+49 -28
View File
@@ -42,6 +42,9 @@ import type {
PrincipalPermissionGrant,
PermissionKey,
PrincipalType,
PluginBridgeRequestContext,
PluginDataHandler,
PluginActionHandler,
} from "./types.js";
import type {
PluginEnvironmentValidateConfigParams,
@@ -57,8 +60,6 @@ import type {
PluginEnvironmentRealizeWorkspaceResult,
PluginEnvironmentExecuteParams,
PluginEnvironmentExecuteResult,
PluginPerformActionActorContext,
PluginPerformActionContext,
} from "./protocol.js";
export interface TestHarnessOptions {
@@ -78,10 +79,9 @@ export interface TestHarnessLogEntry {
export interface TestHarnessPerformActionOptions {
/**
* Authenticated actor context to expose to the action handler. Omitted fields
* default to null, and `type` defaults to `system`.
* Authenticated actor context to expose to the action handler.
*/
actor?: Partial<PluginPerformActionActorContext> | null;
actor?: Partial<NonNullable<PluginBridgeRequestContext["actor"]> & { type?: "user" | "agent" | "system"; companyId?: string | null }> | null;
/**
* Host-authorized company scope. When provided, this is injected into
* `params.companyId` so tests match the production bridge's anti-spoofing
@@ -112,12 +112,12 @@ export interface TestHarness {
/** Execute a previously-registered scheduled job handler. */
runJob(jobKey: string, partial?: Partial<PluginJobContext>): Promise<void>;
/** Invoke a `ctx.data.register(...)` handler by key. */
getData<T = unknown>(key: string, params?: Record<string, unknown>): Promise<T>;
getData<T = unknown>(key: string, params?: Record<string, unknown>, context?: Partial<PluginBridgeRequestContext>): Promise<T>;
/** Invoke a `ctx.actions.register(...)` handler by key. */
performAction<T = unknown>(
key: string,
params?: Record<string, unknown>,
options?: TestHarnessPerformActionOptions,
options?: TestHarnessPerformActionOptions | Partial<PluginBridgeRequestContext>,
): Promise<T>;
/** Execute a registered tool handler via `ctx.tools.execute(...)`. */
executeTool<T = ToolResult>(name: string, params: unknown, runCtx?: Partial<ToolRunContext>): Promise<T>;
@@ -510,11 +510,8 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness {
const events: EventRegistration[] = [];
const jobs = new Map<string, (job: PluginJobContext) => Promise<void>>();
const launchers = new Map<string, PluginLauncherRegistration>();
const dataHandlers = new Map<string, (params: Record<string, unknown>) => Promise<unknown>>();
const actionHandlers = new Map<
string,
(params: Record<string, unknown>, context: PluginPerformActionContext) => Promise<unknown>
>();
const dataHandlers = new Map<string, PluginDataHandler>();
const actionHandlers = new Map<string, PluginActionHandler>();
const toolHandlers = new Map<string, (params: unknown, runCtx: ToolRunContext) => Promise<ToolResult>>();
function localFolderKey(companyId: string, folderKey: string): string {
@@ -529,29 +526,29 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}
function actorTypeOrSystem(value: unknown): PluginPerformActionActorContext["type"] {
return value === "user" || value === "agent" || value === "system" ? value : "system";
}
function actionContextFor(
params: Record<string, unknown>,
options?: TestHarnessPerformActionOptions,
): PluginPerformActionContext {
): PluginBridgeRequestContext {
const actorInput = options?.actor ?? null;
const companyId = stringOrNull(options?.companyId) ?? stringOrNull(actorInput?.companyId) ?? stringOrNull(params.companyId);
const actorType = actorInput?.actorType === "agent" || actorInput?.type === "agent" ? "agent" : "user";
const userId = stringOrNull(actorInput?.userId);
const agentId = stringOrNull(actorInput?.agentId);
const actor = Object.freeze({
type: actorTypeOrSystem(actorInput?.type),
userId: stringOrNull(actorInput?.userId),
agentId: stringOrNull(actorInput?.agentId),
actorType,
actorId: stringOrNull(actorInput?.actorId) ?? (actorType === "agent" ? agentId ?? "agent-test" : userId ?? "user-test"),
userId,
agentId,
runId: stringOrNull(actorInput?.runId),
companyId,
source: stringOrNull(actorInput?.source),
});
return Object.freeze({ actor, companyId });
return Object.freeze({ actor, companyId, renderEnvironment: null });
}
function paramsWithHostCompanyScope(
params: Record<string, unknown>,
context: PluginPerformActionContext,
context: PluginBridgeRequestContext,
options?: TestHarnessPerformActionOptions,
): Record<string, unknown> {
if (Object.prototype.hasOwnProperty.call(options ?? {}, "companyId")) {
@@ -560,6 +557,17 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness {
return params;
}
function isBridgeRequestContextOptions(
options: TestHarnessPerformActionOptions | Partial<PluginBridgeRequestContext> | undefined,
): options is Partial<PluginBridgeRequestContext> {
if (!options || typeof options !== "object") return false;
const actor = (options as Partial<PluginBridgeRequestContext>).actor;
return Boolean(
"renderEnvironment" in options
|| (actor && typeof actor === "object" && "actorType" in actor),
);
}
function normalizeLocalFolderRelativePath(relativePath: string): string {
const parts: string[] = [];
for (const segment of relativePath.split(/[\\/]+/)) {
@@ -2355,20 +2363,33 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness {
scheduledAt: partial.scheduledAt ?? new Date().toISOString(),
});
},
async getData<T = unknown>(key: string, params: Record<string, unknown> = {}) {
async getData<T = unknown>(key: string, params: Record<string, unknown> = {}, context: Partial<PluginBridgeRequestContext> = {}) {
const handler = dataHandlers.get(key);
if (!handler) throw new Error(`No data handler registered for '${key}'`);
return await handler(params) as T;
return await handler(params, {
actor: context.actor ?? null,
companyId: context.companyId ?? null,
renderEnvironment: context.renderEnvironment ?? null,
}) as T;
},
async performAction<T = unknown>(
key: string,
params: Record<string, unknown> = {},
options?: TestHarnessPerformActionOptions,
options?: TestHarnessPerformActionOptions | Partial<PluginBridgeRequestContext>,
) {
const handler = actionHandlers.get(key);
if (!handler) throw new Error(`No action handler registered for '${key}'`);
const context = actionContextFor(params, options);
return await handler(paramsWithHostCompanyScope(params, context, options), context) as T;
const bridgeContext = isBridgeRequestContextOptions(options)
? {
actor: options.actor ?? null,
companyId: options.companyId ?? null,
renderEnvironment: options.renderEnvironment ?? null,
}
: actionContextFor(params, options as TestHarnessPerformActionOptions | undefined);
return await handler(
paramsWithHostCompanyScope(params, bridgeContext, options as TestHarnessPerformActionOptions | undefined),
bridgeContext,
) as T;
},
async executeTool<T = ToolResult>(name: string, params: unknown, runCtx: Partial<ToolRunContext> = {}) {
const handler = toolHandlers.get(name);
+29 -9
View File
@@ -46,8 +46,6 @@ import type {
PrincipalPermissionGrant,
PrincipalType,
} from "@paperclipai/shared";
import type { PluginPerformActionContext } from "./protocol.js";
// ---------------------------------------------------------------------------
// Re-exports from @paperclipai/shared (plugin authors import from one place)
// ---------------------------------------------------------------------------
@@ -928,6 +926,31 @@ export interface PluginSkillsClient {
};
}
export interface PluginBridgeActorContext {
actorType: "user" | "agent";
actorId: string;
userId: string | null;
agentId: string | null;
runId: string | null;
source: string | null;
}
export interface PluginBridgeRequestContext {
actor: PluginBridgeActorContext | null;
companyId: string | null;
renderEnvironment?: PluginLauncherRenderContextSnapshot | null;
}
export type PluginDataHandler = (
params: Record<string, unknown>,
context: PluginBridgeRequestContext,
) => Promise<unknown>;
export type PluginActionHandler = (
params: Record<string, unknown>,
context: PluginBridgeRequestContext,
) => Promise<unknown>;
/**
* `ctx.data` — register `getData` handlers that back `usePluginData()` in the
* plugin's frontend components.
@@ -942,9 +965,9 @@ export interface PluginDataClient {
* Register a handler for a plugin-defined data key.
*
* @param key - Stable string identifier for this data type (e.g. `"sync-health"`)
* @param handler - Async function that receives request params and returns JSON-serializable data
* @param handler - Async function that receives request params plus trusted host context and returns JSON-serializable data
*/
register(key: string, handler: (params: Record<string, unknown>) => Promise<unknown>): void;
register(key: string, handler: PluginDataHandler): void;
}
/**
@@ -958,12 +981,9 @@ export interface PluginActionsClient {
* Register a handler for a plugin-defined action key.
*
* @param key - Stable string identifier for this action (e.g. `"resync"`)
* @param handler - Async function that receives action params plus immutable host actor context and returns a result
* @param handler - Async function that receives action params plus trusted host context and returns a result
*/
register(
key: string,
handler: (params: Record<string, unknown>, context: PluginPerformActionContext) => Promise<unknown>,
): void;
register(key: string, handler: PluginActionHandler): void;
}
/**
+31 -28
View File
@@ -64,6 +64,10 @@ import type {
ToolResult,
EventFilter,
AgentSessionEvent,
PluginDataHandler,
PluginActionHandler,
PluginBridgeActorContext,
PluginBridgeRequestContext,
} from "./types.js";
import type {
JsonRpcId,
@@ -78,8 +82,6 @@ import type {
RunJobParams,
GetDataParams,
PerformActionParams,
PluginPerformActionActorContext,
PluginPerformActionContext,
ExecuteToolParams,
PluginEnvironmentAcquireLeaseParams,
PluginEnvironmentDestroyLeaseParams,
@@ -290,11 +292,8 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
const eventHandlers: EventRegistration[] = [];
const jobHandlers = new Map<string, (job: PluginJobContext) => Promise<void>>();
const launcherRegistrations = new Map<string, PluginLauncherRegistration>();
const dataHandlers = new Map<string, (params: Record<string, unknown>) => Promise<unknown>>();
const actionHandlers = new Map<
string,
(params: Record<string, unknown>, context: PluginPerformActionContext) => Promise<unknown>
>();
const dataHandlers = new Map<string, PluginDataHandler>();
const actionHandlers = new Map<string, PluginActionHandler>();
const toolHandlers = new Map<string, {
declaration: Pick<import("@paperclipai/shared").PluginToolDeclaration, "displayName" | "description" | "parametersSchema">;
fn: (params: unknown, runCtx: ToolRunContext) => Promise<ToolResult>;
@@ -1189,10 +1188,7 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
},
actions: {
register(
key: string,
handler: (params: Record<string, unknown>, context: PluginPerformActionContext) => Promise<unknown>,
): void {
register(key: string, handler: PluginActionHandler): void {
actionHandlers.set(key, handler);
},
},
@@ -1519,31 +1515,38 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
...params.params,
...(params.companyId === undefined ? {} : { companyId: params.companyId }),
...(params.renderEnvironment === undefined ? {} : { renderEnvironment: params.renderEnvironment }),
});
}, bridgeRequestContextFromParams(params));
}
function stringOrNull(value: unknown): string | null {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}
function actorTypeOrSystem(value: unknown): PluginPerformActionActorContext["type"] {
return value === "user" || value === "agent" || value === "system" ? value : "system";
function bridgeActorFromLegacyContext(value: unknown): PluginBridgeActorContext | null {
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
const rawActor = value as Record<string, unknown>;
const actorType = rawActor.type === "agent" ? "agent" : "user";
const userId = stringOrNull(rawActor.userId);
const agentId = stringOrNull(rawActor.agentId);
return Object.freeze({
actorType,
actorId: actorType === "agent" ? agentId ?? "unknown-agent" : userId ?? "board",
userId,
agentId,
runId: stringOrNull(rawActor.runId),
source: null,
});
}
function actionContextFromParams(params: PerformActionParams): PluginPerformActionContext {
const rawActor = params.actorContext && typeof params.actorContext === "object"
? params.actorContext
: null;
const actor = Object.freeze({
type: actorTypeOrSystem(rawActor?.type),
userId: stringOrNull(rawActor?.userId),
agentId: stringOrNull(rawActor?.agentId),
runId: stringOrNull(rawActor?.runId),
companyId: stringOrNull(rawActor?.companyId),
});
function bridgeRequestContextFromParams(params: GetDataParams | PerformActionParams): PluginBridgeRequestContext {
const actor = params.actor ?? bridgeActorFromLegacyContext("actorContext" in params ? params.actorContext : null);
const companyId = stringOrNull(params.companyId)
?? stringOrNull("actorContext" in params && params.actorContext ? params.actorContext.companyId : null);
const renderEnvironment = params.renderEnvironment ?? null;
return Object.freeze({
actor,
companyId: actor.companyId,
actor: actor ? Object.freeze(actor) : null,
companyId,
renderEnvironment,
});
}
@@ -1558,7 +1561,7 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
...(params.companyId === undefined ? {} : { companyId: params.companyId }),
...(params.renderEnvironment === undefined ? {} : { renderEnvironment: params.renderEnvironment }),
},
actionContextFromParams(params),
bridgeRequestContextFromParams(params),
);
}
@@ -0,0 +1,44 @@
import { describe, expect, it } from "vitest";
import { createTestHarness } from "../src/testing.js";
import { definePlugin } from "../src/define-plugin.js";
import type { PaperclipPluginManifestV1 } from "../src/types.js";
const manifest: PaperclipPluginManifestV1 = {
apiVersion: 1,
id: "paperclip.test.bridge-context",
displayName: "Bridge Context Test",
version: "0.0.0",
description: "Tests trusted bridge request context.",
author: "Paperclip",
categories: ["ui"],
capabilities: [],
entrypoints: {
worker: "./dist/worker.js",
},
};
describe("plugin bridge request context", () => {
it("makes trusted actor context available to data and action handlers", async () => {
const plugin = definePlugin({
async setup(ctx) {
ctx.data.register("whoami", async (_params, request) => request.actor);
ctx.actions.register("whoami", async (_params, request) => request.actor);
},
});
const harness = createTestHarness({ manifest });
await plugin.definition.setup?.(harness.ctx);
const actor = {
actorType: "user" as const,
actorId: "user-1",
userId: "user-1",
agentId: null,
runId: "run-1",
source: "session",
};
await expect(harness.getData("whoami", {}, { actor })).resolves.toEqual(actor);
await expect(harness.performAction("whoami", {}, { actor })).resolves.toEqual(actor);
});
});
@@ -30,11 +30,12 @@ describe("createTestHarness action context", () => {
const result = await harness.performAction<{
paramsCompanyId: unknown;
actor: {
type: string;
actorType: string;
actorId: string;
userId: string | null;
agentId: string | null;
runId: string | null;
companyId: string | null;
source: string | null;
};
companyId: string | null;
contextFrozen: boolean;
@@ -55,11 +56,12 @@ describe("createTestHarness action context", () => {
expect(result.paramsCompanyId).toBe("host-company");
expect(result.companyId).toBe("host-company");
expect(result.actor).toEqual({
type: "user",
actorType: "user",
actorId: "board-user-1",
userId: "board-user-1",
agentId: null,
runId: "run-1",
companyId: "host-company",
source: null,
});
expect(result.contextFrozen).toBe(true);
expect(result.actorFrozen).toBe(true);
@@ -642,6 +642,50 @@ describe.sequential("plugin tool and bridge authz", () => {
key: "health",
companyId: companyA,
params: { view: "compact" },
actor: {
actorType: "user",
actorId: "user-1",
userId: "user-1",
agentId: null,
runId: null,
source: "session",
},
renderEnvironment: null,
});
});
it("passes trusted actor context to plugin data handlers without trusting caller user params", async () => {
readyPlugin();
const call = vi.fn().mockResolvedValue({ ok: true });
const { app } = await createApp(boardActor({
userId: "signed-in-user",
companyIds: [companyA],
}), {}, {
bridgeDeps: {
workerManager: { call },
},
});
const res = await request(app)
.post(`/api/plugins/${pluginId}/data/page`)
.send({
companyId: companyA,
params: { companyId: companyA, userId: "victim-user" },
});
expect(res.status).toBe(200);
expect(call).toHaveBeenCalledWith(pluginId, "getData", {
key: "page",
companyId: companyA,
params: { companyId: companyA, userId: "victim-user" },
actor: {
actorType: "user",
actorId: "signed-in-user",
userId: "signed-in-user",
agentId: null,
runId: null,
source: "session",
},
renderEnvironment: null,
});
});
@@ -674,6 +718,14 @@ describe.sequential("plugin tool and bridge authz", () => {
runId: null,
companyId: null,
},
actor: {
actorType: "user",
actorId: "admin-1",
userId: "admin-1",
agentId: null,
runId: null,
source: "session",
},
renderEnvironment: null,
});
});
@@ -711,6 +763,14 @@ describe.sequential("plugin tool and bridge authz", () => {
runId: runA,
companyId: companyA,
},
actor: {
actorType: "user",
actorId: "user-1",
userId: "user-1",
agentId: null,
runId: runA,
source: "session",
},
renderEnvironment: null,
});
});
@@ -735,6 +795,11 @@ describe.sequential("plugin tool and bridge authz", () => {
userId: null,
companyId: companyA,
}),
actor: expect.objectContaining({
actorType: "user",
actorId: "board",
userId: null,
}),
}));
});
@@ -771,6 +836,14 @@ describe.sequential("plugin tool and bridge authz", () => {
runId: runA,
companyId: companyA,
},
actor: {
actorType: "agent",
actorId: agentA,
userId: null,
agentId: agentA,
runId: runA,
source: "agent_jwt",
},
renderEnvironment: null,
});
@@ -800,6 +873,14 @@ describe.sequential("plugin tool and bridge authz", () => {
runId: runA,
companyId: companyA,
},
actor: {
actorType: "agent",
actorId: agentA,
userId: null,
agentId: agentA,
runId: runA,
source: "agent_jwt",
},
renderEnvironment: null,
});
});
+17 -1
View File
@@ -55,7 +55,7 @@ import type { PluginJobStore } from "../services/plugin-job-store.js";
import type { PluginWorkerManager } from "../services/plugin-worker-manager.js";
import type { PluginStreamBus } from "../services/plugin-stream-bus.js";
import type { PluginToolDispatcher } from "../services/plugin-tool-dispatcher.js";
import type { PluginPerformActionActorContext, ToolRunContext } from "@paperclipai/plugin-sdk";
import type { PluginBridgeActorContext, PluginPerformActionActorContext, ToolRunContext } from "@paperclipai/plugin-sdk";
import { JsonRpcCallError, PLUGIN_RPC_ERROR_CODES } from "@paperclipai/plugin-sdk";
import {
assertAuthenticated,
@@ -601,6 +601,18 @@ export function pluginRoutes(
};
}
function trustedPluginBridgeActor(req: Request): PluginBridgeActorContext {
const actor = getActorInfo(req);
return {
actorType: actor.actorType,
actorId: actor.actorId,
userId: req.actor.type === "board" ? req.actor.userId ?? null : null,
agentId: req.actor.type === "agent" ? req.actor.agentId ?? null : null,
runId: actor.runId,
source: req.actor.source ?? null,
};
}
function actionParamsWithAuthorizedCompanyScope(
params: Record<string, unknown> | undefined,
companyId: string | undefined,
@@ -1169,6 +1181,7 @@ export function pluginRoutes(
key: body.key,
...(companyId ? { companyId } : {}),
params: body.params ?? {},
actor: trustedPluginBridgeActor(req),
renderEnvironment: body.renderEnvironment ?? null,
},
);
@@ -1262,6 +1275,7 @@ export function pluginRoutes(
key: body.key,
params: actionParamsWithAuthorizedCompanyScope(body.params, companyId),
actorContext: performActionActorContext(req, companyId),
actor: trustedPluginBridgeActor(req),
renderEnvironment: body.renderEnvironment ?? null,
},
);
@@ -1356,6 +1370,7 @@ export function pluginRoutes(
key,
...(companyId ? { companyId } : {}),
params: body?.params ?? {},
actor: trustedPluginBridgeActor(req),
renderEnvironment: body?.renderEnvironment ?? null,
},
);
@@ -1446,6 +1461,7 @@ export function pluginRoutes(
key,
params: actionParamsWithAuthorizedCompanyScope(body?.params, companyId),
actorContext: performActionActorContext(req, companyId),
actor: trustedPluginBridgeActor(req),
renderEnvironment: body?.renderEnvironment ?? null,
},
);