mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-08 01:57:31 +01:00
Extend native instance secrets to project lifecycle plugins (#17034)
This commit is contained in:
1 parent
c2daf906d9
commit
65f59e339a
23 files changed
+5880
-4748
No files matched your search
@@ -0,0 +1,21 @@
|
||||
CREATE UNIQUE INDEX "projects_company_id_uq" ON "projects" USING btree ("company_id","id");--> statement-breakpoint
|
||||
ALTER TABLE "company_secrets" DROP CONSTRAINT "company_secrets_scope_shape_check";--> statement-breakpoint
|
||||
ALTER TABLE "company_secrets" ADD COLUMN "owner_project_id" uuid;--> statement-breakpoint
|
||||
ALTER TABLE "company_secrets" ADD CONSTRAINT "company_secrets_company_id_owner_project_id_projects_company_id_id_fk" FOREIGN KEY ("company_id","owner_project_id") REFERENCES "public"."projects"("company_id","id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "company_secrets_project_instance_owner_key_uq" ON "company_secrets" USING btree ("company_id","owner_project_id","owner_plugin_id","key") WHERE "company_secrets"."scope" = 'instance' and "company_secrets"."deleted_at" is null;--> statement-breakpoint
|
||||
ALTER TABLE "company_secrets" ADD CONSTRAINT "company_secrets_scope_shape_check" CHECK ((
|
||||
"company_secrets"."scope" = 'company'
|
||||
and "company_secrets"."owner_user_id" is null
|
||||
and "company_secrets"."user_secret_definition_id" is null
|
||||
and "company_secrets"."owner_agent_id" is null and "company_secrets"."owner_project_id" is null and "company_secrets"."owner_plugin_id" is null
|
||||
) or (
|
||||
"company_secrets"."scope" = 'user'
|
||||
and "company_secrets"."owner_user_id" is not null
|
||||
and "company_secrets"."user_secret_definition_id" is not null
|
||||
and "company_secrets"."owner_agent_id" is null and "company_secrets"."owner_project_id" is null and "company_secrets"."owner_plugin_id" is null
|
||||
) or (
|
||||
"company_secrets"."scope" = 'instance'
|
||||
and "company_secrets"."owner_user_id" is null and "company_secrets"."user_secret_definition_id" is null
|
||||
and ("company_secrets"."owner_agent_id" is not null) <> ("company_secrets"."owner_project_id" is not null)
|
||||
and "company_secrets"."owner_plugin_id" is not null
|
||||
));
|
||||
+4896
-4701
File diff suppressed because it is too large.
Load diff
+7
@@ -2577,6 +2577,13 @@
|
||||
"when": 1792541385924,
|
||||
"tag": "0369_late_logan",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 370,
|
||||
"version": "7",
|
||||
"when": 1792544911793,
|
||||
"tag": "0370_flimsy_lord_tyger",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { sql } from "drizzle-orm";
|
||||
import { check, pgTable, uuid, text, timestamp, integer, jsonb, index, uniqueIndex, foreignKey } from "drizzle-orm/pg-core";
|
||||
import { companies } from "./companies.js";
|
||||
import { projects } from "./projects.js";
|
||||
import { agents } from "./agents.js";
|
||||
import { companySecretProviderConfigs } from "./company_secret_provider_configs.js";
|
||||
import { userSecretDefinitions } from "./user_secret_definitions.js";
|
||||
@@ -14,6 +15,7 @@ export const companySecrets = pgTable(
|
||||
scope: text("scope").notNull().default("company"),
|
||||
ownerUserId: text("owner_user_id"),
|
||||
ownerAgentId: uuid("owner_agent_id"),
|
||||
ownerProjectId: uuid("owner_project_id"),
|
||||
ownerPluginId: uuid("owner_plugin_id").references(() => plugins.id, { onDelete: "cascade" }),
|
||||
userSecretDefinitionId: uuid("user_secret_definition_id").references(() => userSecretDefinitions.id, { onDelete: "set null" }),
|
||||
key: text("key").notNull(),
|
||||
@@ -46,6 +48,9 @@ export const companySecrets = pgTable(
|
||||
),
|
||||
companyProviderIdx: index("company_secrets_company_provider_idx").on(table.companyId, table.provider),
|
||||
agentOwner: foreignKey({ columns: [table.companyId, table.ownerAgentId], foreignColumns: [agents.companyId, agents.id] }).onDelete("cascade"),
|
||||
projectOwner: foreignKey({ columns: [table.companyId, table.ownerProjectId], foreignColumns: [projects.companyId, projects.id] }).onDelete("cascade"),
|
||||
projectInstanceOwnerKeyUq: uniqueIndex("company_secrets_project_instance_owner_key_uq").on(table.companyId, table.ownerProjectId, table.ownerPluginId, table.key)
|
||||
.where(sql`${table.scope} = 'instance' and ${table.deletedAt} is null`),
|
||||
instanceOwnerKeyUq: uniqueIndex("company_secrets_instance_owner_key_uq").on(table.companyId, table.ownerAgentId, table.ownerPluginId, table.key)
|
||||
.where(sql`${table.scope} = 'instance' and ${table.deletedAt} is null`),
|
||||
providerConfigIdx: index("company_secrets_provider_config_idx").on(table.providerConfigId),
|
||||
@@ -64,16 +69,17 @@ export const companySecrets = pgTable(
|
||||
${table.scope} = 'company'
|
||||
and ${table.ownerUserId} is null
|
||||
and ${table.userSecretDefinitionId} is null
|
||||
and ${table.ownerAgentId} is null and ${table.ownerPluginId} is null
|
||||
and ${table.ownerAgentId} is null and ${table.ownerProjectId} is null and ${table.ownerPluginId} is null
|
||||
) or (
|
||||
${table.scope} = 'user'
|
||||
and ${table.ownerUserId} is not null
|
||||
and ${table.userSecretDefinitionId} is not null
|
||||
and ${table.ownerAgentId} is null and ${table.ownerPluginId} is null
|
||||
and ${table.ownerAgentId} is null and ${table.ownerProjectId} is null and ${table.ownerPluginId} is null
|
||||
) or (
|
||||
${table.scope} = 'instance'
|
||||
and ${table.ownerUserId} is null and ${table.userSecretDefinitionId} is null
|
||||
and ${table.ownerAgentId} is not null and ${table.ownerPluginId} is not null
|
||||
and (${table.ownerAgentId} is not null) <> (${table.ownerProjectId} is not null)
|
||||
and ${table.ownerPluginId} is not null
|
||||
)`,
|
||||
),
|
||||
}),
|
||||
|
||||
@@ -30,6 +30,7 @@ export const projects = pgTable(
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => ({
|
||||
companyIdUq: uniqueIndex("projects_company_id_uq").on(table.companyId, table.id),
|
||||
companyIdx: index("projects_company_idx").on(table.companyId),
|
||||
visibilityCheck: check("projects_visibility_check", sql`${table.visibility} in ('open', 'private')`),
|
||||
personalOwnerUq: uniqueIndex("projects_company_personal_owner_uq")
|
||||
|
||||
@@ -1373,3 +1373,29 @@ other Core-host filesystem operations are unavailable for managed storage.
|
||||
|
||||
Use `createWorkspaceProviderHarness` from `@paperclipai/plugin-sdk/testing` to
|
||||
validate hook inputs and outputs without allocating an execution environment.
|
||||
|
||||
|
||||
### Private project instance credentials
|
||||
|
||||
Project lifecycle plugins can opt into `projects.instance-secrets` alongside
|
||||
`events.subscribe` and `resourceLifecycle: ["project"]`. Automatic project
|
||||
callbacks receive `event.instanceSecrets`; return an `instanceSecrets` patch
|
||||
with an acknowledged result. Core stores the patch in the existing native
|
||||
instance-secret system and acknowledges the event in one transaction. A null
|
||||
value removes a key. Retry and deferred results do not store patches.
|
||||
|
||||
Each company/project/installed-plugin owns its credentials. Up to 16 lowercase
|
||||
snake_case keys may change per result; nonempty string values are limited to
|
||||
128 KiB each. Core rejects replies if configuration, project sources, lifecycle
|
||||
state, or secret versions changed during the callback. A duplicate response to
|
||||
an acknowledged event cannot overwrite credentials. Deleted projects allow
|
||||
removal only; their secrets are also deleted by the native owner foreign key.
|
||||
|
||||
Trusted worker code can use
|
||||
`ctx.projects.getInstanceSecrets(projectId, companyId)` during a company-scoped
|
||||
invocation to read its credentials without an external service call. Archived
|
||||
or missing projects cannot be read this way. Values never appear in ordinary
|
||||
lifecycle inbox listings or the company secret catalog. Do not expose values
|
||||
through plugin entities, state, data handlers, UI, or logs. Agent instance
|
||||
secrets retain their existing lifecycle contract. Back up the tenant's existing
|
||||
local secrets master key together with its database.
|
||||
@@ -202,6 +202,7 @@ export interface HostServices {
|
||||
projects: {
|
||||
list(params: WorkerToHostMethods["projects.list"][0]): Promise<WorkerToHostMethods["projects.list"][1]>;
|
||||
get(params: WorkerToHostMethods["projects.get"][0]): Promise<WorkerToHostMethods["projects.get"][1]>;
|
||||
getInstanceSecrets(params: WorkerToHostMethods["projects.getInstanceSecrets"][0]): Promise<WorkerToHostMethods["projects.getInstanceSecrets"][1]>;
|
||||
listWorkspaces(params: WorkerToHostMethods["projects.listWorkspaces"][0]): Promise<WorkerToHostMethods["projects.listWorkspaces"][1]>;
|
||||
getPrimaryWorkspace(params: WorkerToHostMethods["projects.getPrimaryWorkspace"][0]): Promise<WorkerToHostMethods["projects.getPrimaryWorkspace"][1]>;
|
||||
getWorkspaceForIssue(params: WorkerToHostMethods["projects.getWorkspaceForIssue"][0]): Promise<WorkerToHostMethods["projects.getWorkspaceForIssue"][1]>;
|
||||
@@ -440,6 +441,7 @@ const METHOD_CAPABILITY_MAP: Record<WorkerToHostMethodName, PluginCapability | n
|
||||
// Projects
|
||||
"projects.list": "projects.read",
|
||||
"projects.get": "projects.read",
|
||||
"projects.getInstanceSecrets": "projects.instance-secrets",
|
||||
"projects.listWorkspaces": "project.workspaces.read",
|
||||
"projects.getPrimaryWorkspace": "project.workspaces.read",
|
||||
"projects.getWorkspaceForIssue": "project.workspaces.read",
|
||||
@@ -830,6 +832,10 @@ export function createHostClientHandlers(
|
||||
"projects.list": gated("projects.list", async (params) => {
|
||||
return services.projects.list(params);
|
||||
}),
|
||||
"projects.getInstanceSecrets": gated("projects.getInstanceSecrets", async (params, context) => {
|
||||
const companyId = resolveRequiredCompanyId("projects.getInstanceSecrets", params, context);
|
||||
return services.projects.getInstanceSecrets({ ...params, companyId });
|
||||
}),
|
||||
"projects.get": gated("projects.get", async (params) => {
|
||||
return services.projects.get(params);
|
||||
}),
|
||||
|
||||
@@ -1794,6 +1794,10 @@ export interface WorkerToHostMethods {
|
||||
params: { companyId: string; limit?: number; offset?: number },
|
||||
result: Project[],
|
||||
];
|
||||
"projects.getInstanceSecrets": [
|
||||
params: { projectId: string; companyId: string },
|
||||
result: Record<string, string>,
|
||||
];
|
||||
"projects.get": [
|
||||
params: { projectId: string; companyId: string },
|
||||
result: Project | null,
|
||||
|
||||
@@ -112,6 +112,7 @@ export interface TestHarness {
|
||||
seed(input: {
|
||||
companies?: Company[];
|
||||
lifecycleEvents?: ResourceLifecycleEvent[];
|
||||
projectInstanceSecrets?: Array<{ companyId: string; projectId: string; values: Record<string, string> }>;
|
||||
projects?: Project[];
|
||||
issues?: Issue[];
|
||||
issueComments?: IssueComment[];
|
||||
@@ -506,6 +507,7 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness {
|
||||
const lifecycleEvents = new Map<string, ResourceLifecycleEvent>();
|
||||
const lifecycleAcknowledgments = new Set<string>();
|
||||
const projects = new Map<string, Project>();
|
||||
const projectInstanceSecrets = new Map<string, Record<string, string>>();
|
||||
const routines = new Map<string, Routine>();
|
||||
const routineRuns = new Map<string, RoutineRun>();
|
||||
const issues = new Map<string, Issue>();
|
||||
@@ -1030,6 +1032,14 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness {
|
||||
},
|
||||
},
|
||||
projects: {
|
||||
async getInstanceSecrets(projectId, companyId) {
|
||||
requireCapability(manifest, capabilitySet, "projects.instance-secrets");
|
||||
requireCapability(manifest, capabilitySet, "events.subscribe");
|
||||
if (!manifest.resourceLifecycle?.includes("project")) throw new Error("Project instance secrets outside plugin scope");
|
||||
const project = projects.get(projectId);
|
||||
if (!isInCompany(project, companyId) || project.archivedAt) throw new Error("Project instance secrets unavailable");
|
||||
return structuredClone(projectInstanceSecrets.get(JSON.stringify([companyId, projectId])) ?? {});
|
||||
},
|
||||
async list(input) {
|
||||
requireCapability(manifest, capabilitySet, "projects.read");
|
||||
const companyId = requireCompanyId(input?.companyId);
|
||||
@@ -2586,6 +2596,7 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness {
|
||||
for (const row of input.lifecycleEvents ?? []) lifecycleEvents.set(row.id, row);
|
||||
for (const row of input.companies ?? []) companies.set(row.id, row);
|
||||
for (const row of input.projects ?? []) projects.set(row.id, row);
|
||||
for (const row of input.projectInstanceSecrets ?? []) projectInstanceSecrets.set(JSON.stringify([row.companyId, row.projectId]), structuredClone(row.values));
|
||||
for (const row of input.issues ?? []) {
|
||||
issues.set(row.id, row);
|
||||
if (row.blockedBy) {
|
||||
|
||||
@@ -592,6 +592,9 @@ export interface PluginEventsClient {
|
||||
}
|
||||
|
||||
export type ResourceLifecycleEvent = {
|
||||
/** Native private credentials for this plugin/project. Only supplied to automatic
|
||||
* project lifecycle callbacks with projects.instance-secrets. Never expose to UI. */
|
||||
instanceSecrets?: Record<string, string>;
|
||||
id: string;
|
||||
companyId: string;
|
||||
resourceId: string;
|
||||
@@ -609,6 +612,9 @@ export type ResourceLifecycleEvent = {
|
||||
export interface ResourceLifecycleResult {
|
||||
eventId: string;
|
||||
status: "acknowledged" | "retry" | "deferred";
|
||||
/** Project-only patch, saved atomically with acknowledgment. Null removes a key.
|
||||
* Up to 16 lowercase snake_case keys, each value at most 128 KiB. */
|
||||
instanceSecrets?: Record<string, string | null>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -869,6 +875,10 @@ export interface PluginEntitiesClient {
|
||||
* @see PLUGIN_SPEC.md §7 — Project Workspaces
|
||||
*/
|
||||
export interface PluginProjectsClient {
|
||||
/** Trusted worker access to this plugin’s native project credentials.
|
||||
* Requires projects.instance-secrets; never expose via UI/data handlers. */
|
||||
getInstanceSecrets(projectId: string, companyId: string): Promise<Record<string, string>>;
|
||||
|
||||
/**
|
||||
* List projects visible to the plugin.
|
||||
*
|
||||
|
||||
@@ -730,6 +730,9 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
|
||||
async get(projectId: string, companyId: string) {
|
||||
return callHost("projects.get", { projectId, companyId });
|
||||
},
|
||||
async getInstanceSecrets(projectId, companyId) {
|
||||
return callHost("projects.getInstanceSecrets", { projectId, companyId });
|
||||
},
|
||||
|
||||
async listWorkspaces(projectId: string, companyId: string) {
|
||||
return callHost("projects.listWorkspaces", { projectId, companyId });
|
||||
|
||||
@@ -435,3 +435,20 @@ describe("agent runtime reader", () => {
|
||||
expect(getRuntime).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
describe("project instance secrets", () => {
|
||||
it("requires the capability and host-authorized company scope", async () => {
|
||||
const getInstanceSecrets = vi.fn(async () => ({ mount_grants: "fixture" }));
|
||||
const services = { projects: { getInstanceSecrets } } as unknown as HostServices;
|
||||
const params = { companyId: "company-a", projectId: "project-a" };
|
||||
const context = { invocationScope: { companyId: "company-a" } };
|
||||
const denied = createHostClientHandlers({ pluginId: "fixture", capabilities: [], services });
|
||||
await expect(denied["projects.getInstanceSecrets"](params, context)).rejects.toBeInstanceOf(CapabilityDeniedError);
|
||||
const allowed = createHostClientHandlers({ pluginId: "fixture", capabilities: ["projects.instance-secrets"], services });
|
||||
await expect(allowed["projects.getInstanceSecrets"](params)).rejects.toBeInstanceOf(InvocationScopeDeniedError);
|
||||
await expect(allowed["projects.getInstanceSecrets"](params, { invocationScope: { companyId: "other" } })).rejects.toBeInstanceOf(InvocationScopeDeniedError);
|
||||
expect(getInstanceSecrets).not.toHaveBeenCalled();
|
||||
await expect(allowed["projects.getInstanceSecrets"](params, context)).resolves.toEqual({ mount_grants: "fixture" });
|
||||
expect(getInstanceSecrets).toHaveBeenCalledWith(params);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createTestHarness } from "../src/testing.js";
|
||||
import type { PaperclipPluginManifestV1 } from "../src/types.js";
|
||||
const manifest = {
|
||||
id: "fixture",
|
||||
apiVersion: 1,
|
||||
resourceLifecycle: ["project"],
|
||||
capabilities: ["projects.instance-secrets", "events.subscribe"],
|
||||
} as PaperclipPluginManifestV1;
|
||||
describe("project instance secret harness", () => {
|
||||
it("returns only seeded company/project credentials and isolates returned copies", async () => {
|
||||
const h = createTestHarness({ manifest });
|
||||
h.seed({
|
||||
projects: [{ id: "project", companyId: "company" }] as never,
|
||||
projectInstanceSecrets: [
|
||||
{
|
||||
companyId: "company",
|
||||
projectId: "project",
|
||||
values: { mount_grants: "fixture" },
|
||||
},
|
||||
],
|
||||
});
|
||||
const values = await h.ctx.projects.getInstanceSecrets(
|
||||
"project",
|
||||
"company",
|
||||
);
|
||||
expect(values).toEqual({ mount_grants: "fixture" });
|
||||
values.mount_grants = "changed";
|
||||
expect(
|
||||
await h.ctx.projects.getInstanceSecrets("project", "company"),
|
||||
).toEqual({ mount_grants: "fixture" });
|
||||
await expect(
|
||||
h.ctx.projects.getInstanceSecrets("project", "other"),
|
||||
).rejects.toThrow();
|
||||
await expect(
|
||||
h.ctx.projects.getInstanceSecrets("missing", "company"),
|
||||
).rejects.toThrow();
|
||||
h.seed({
|
||||
projects: [
|
||||
{ id: "project", companyId: "company", archivedAt: "2020-01-01" },
|
||||
] as never,
|
||||
});
|
||||
await expect(
|
||||
h.ctx.projects.getInstanceSecrets("project", "company"),
|
||||
).rejects.toThrow();
|
||||
const denied = createTestHarness({ manifest, capabilities: [] });
|
||||
await expect(
|
||||
denied.ctx.projects.getInstanceSecrets("project", "company"),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -1323,6 +1323,7 @@ export const PLUGIN_CAPABILITIES = [
|
||||
// Data Read
|
||||
"companies.read",
|
||||
"projects.read",
|
||||
"projects.instance-secrets",
|
||||
"project.workspaces.read",
|
||||
"execution.workspaces.read",
|
||||
"workspaces.manage",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { randomBytes, randomUUID } from "node:crypto";
|
||||
import { EventEmitter } from "node:events";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { companies, createDb, pluginConfig, plugins, resourceLifecycleEvents, type Db } from "@paperclipai/db";
|
||||
import { companies, createDb, companySecrets, projects, pluginConfig, plugins, resourceLifecycleEvents, type Db } from "@paperclipai/db";
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
|
||||
import { logger } from "../middleware/logger.js";
|
||||
@@ -54,6 +54,40 @@ describePostgres("durable plugin lifecycle delivery", () => {
|
||||
const worker = registerPluginLifecycleDelivery(coordinator as never, db, { call, isRunning: running, onConfigApplied } as unknown as PluginWorkerManager, lifecycle as unknown as PluginLifecycleManager);
|
||||
return { call, running, wake, lifecycle, worker, applied: () => { for (const listener of configApplied) listener(); }, run: () => task.run(new AbortController().signal), pending: () => task.hasPending(), nextRunAt: () => task.nextRunAt?.(), settled: () => task.onSettled?.() };
|
||||
}
|
||||
it("supplies project native credentials only to opted-in callbacks and saves returned patches before acknowledgment", async () => {
|
||||
vi.stubEnv("PAPERCLIP_SECRETS_MASTER_KEY", randomBytes(32).toString("base64"));
|
||||
await db.update(plugins).set({ manifestJson: { resourceLifecycle: ["project"], capabilities: ["events.subscribe", "projects.instance-secrets"] } as never }).where(eq(plugins.id, pluginId));
|
||||
const [project] = await db.insert(projects).values({ companyId, name: "Native credentials owner" }).returning();
|
||||
await event(project.id);
|
||||
const d = delivery();
|
||||
d.call.mockImplementationOnce(async (_p, _m, input) => {
|
||||
expect(input.instanceSecrets).toEqual({});
|
||||
return { eventId: input.id, status: "acknowledged", instanceSecrets: { mount_grants: "fixture-private-grants" } };
|
||||
});
|
||||
try {
|
||||
await d.run();
|
||||
expect(await pluginLifecycleInbox(db, pluginId).list(companyId)).toEqual([]);
|
||||
expect(await db.select().from(companySecrets).where(eq(companySecrets.ownerProjectId, project.id))).toHaveLength(1);
|
||||
await event(project.id, "update");
|
||||
d.call.mockImplementationOnce(async (_p, _m, input) => {
|
||||
expect(input.instanceSecrets).toEqual({ mount_grants: "fixture-private-grants" });
|
||||
return { eventId: input.id, status: "retry", instanceSecrets: { mount_grants: "must-not-save" } };
|
||||
});
|
||||
await d.run();
|
||||
const [stored] = await db.select().from(companySecrets).where(eq(companySecrets.ownerProjectId, project.id));
|
||||
expect(stored.latestVersion).toBe(1);
|
||||
expect(JSON.stringify(await pluginLifecycleInbox(db, pluginId).list(companyId))).not.toContain("fixture-private-grants");
|
||||
} finally { d.worker.dispose(); vi.unstubAllEnvs(); }
|
||||
});
|
||||
it("rejects credential patches from plugins without project instance secret scope", async () => {
|
||||
await event(); const d = delivery();
|
||||
d.call.mockImplementationOnce(async (_p, _m, input) => {
|
||||
expect(input.instanceSecrets).toBeUndefined();
|
||||
return { eventId: input.id, status: "acknowledged", instanceSecrets: { mount_grants: "private" } };
|
||||
});
|
||||
try { await d.run(); expect(await pluginLifecycleInbox(db, pluginId).list(companyId)).toHaveLength(1); }
|
||||
finally { d.worker.dispose(); }
|
||||
});
|
||||
it.each([false, true])("does not wait for queued plugin callbacks during startup (runtime subscriber: %s)", async runtime => {
|
||||
if (runtime) await db.update(plugins).set({ manifestJson: { resourceLifecycle: ["project", "agent_runtime"], capabilities: ["events.subscribe"] } as never }).where(eq(plugins.id, pluginId));
|
||||
await event();
|
||||
|
||||
@@ -0,0 +1,477 @@
|
||||
import { randomBytes, randomUUID } from "node:crypto";
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import {
|
||||
activityLog,
|
||||
companies,
|
||||
companySecrets,
|
||||
companySecretVersions,
|
||||
pluginCompanySettings,
|
||||
pluginConfig,
|
||||
pluginLifecycleAcknowledgments,
|
||||
plugins,
|
||||
projects,
|
||||
projectWorkspaces,
|
||||
createDb,
|
||||
type Db,
|
||||
} from "@paperclipai/db";
|
||||
import {
|
||||
getEmbeddedPostgresTestSupport,
|
||||
startEmbeddedPostgresTestDatabase,
|
||||
} from "./helpers/embedded-postgres.js";
|
||||
import {
|
||||
projectInstanceSecretService,
|
||||
validateProjectInstanceSecretChanges,
|
||||
} from "../services/project-instance-secrets.js";
|
||||
import { pluginLifecycleInbox } from "../services/plugin-lifecycle-inbox.js";
|
||||
import { recordResourceCreationEvent } from "../services/resource-lifecycle-events.js";
|
||||
import { secretService } from "../services/secrets.js";
|
||||
import { localEncryptedProvider } from "../secrets/local-encrypted-provider.js";
|
||||
import * as activity from "../services/activity-log.js";
|
||||
|
||||
const support = await getEmbeddedPostgresTestSupport();
|
||||
(support.supported ? describe : describe.skip)(
|
||||
"project instance secrets reuse native tenant storage",
|
||||
() => {
|
||||
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>,
|
||||
db: Db;
|
||||
const companyId = randomUUID(),
|
||||
pluginId = randomUUID();
|
||||
const value = "fixture-private-mount-grant";
|
||||
beforeAll(async () => {
|
||||
vi.stubEnv(
|
||||
"PAPERCLIP_SECRETS_MASTER_KEY",
|
||||
randomBytes(32).toString("base64"),
|
||||
);
|
||||
database = await startEmbeddedPostgresTestDatabase(
|
||||
"paperclip-project-instance-secrets-",
|
||||
);
|
||||
db = createDb(database.connectionString);
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Project credentials fixture",
|
||||
issuePrefix: "PCI",
|
||||
});
|
||||
await db.insert(plugins).values({
|
||||
id: pluginId,
|
||||
pluginKey: pluginId,
|
||||
packageName: "project-instance-fixture",
|
||||
version: "1.0.0",
|
||||
status: "ready",
|
||||
manifestJson: {
|
||||
resourceLifecycle: ["project"],
|
||||
capabilities: ["events.subscribe", "projects.instance-secrets"],
|
||||
} as never,
|
||||
});
|
||||
await db
|
||||
.insert(pluginConfig)
|
||||
.values({ pluginId, companyId, configJson: {} });
|
||||
}, 90_000);
|
||||
afterAll(async () => {
|
||||
await database?.cleanup();
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
async function fixture() {
|
||||
const [project] = await db
|
||||
.insert(projects)
|
||||
.values({ companyId, name: "Credential owner" })
|
||||
.returning();
|
||||
await db.transaction((tx) =>
|
||||
recordResourceCreationEvent(
|
||||
tx as unknown as Db,
|
||||
companyId,
|
||||
"project",
|
||||
project.id,
|
||||
),
|
||||
);
|
||||
const inbox = pluginLifecycleInbox(db, pluginId);
|
||||
const event = (await inbox.list(companyId, 100)).find(
|
||||
(e) => e.resourceId === project.id,
|
||||
)!;
|
||||
const service = projectInstanceSecretService(db, pluginId);
|
||||
return {
|
||||
project,
|
||||
event,
|
||||
inbox,
|
||||
service,
|
||||
prepare: () => service.prepare(event),
|
||||
async commit(changes: unknown) {
|
||||
const { snapshot } = await service.prepare(event);
|
||||
await inbox.acknowledge(companyId, event.id, { snapshot, changes });
|
||||
},
|
||||
};
|
||||
}
|
||||
it("encrypts native versions, survives restart, isolates owners, and hides values from catalogs and events", async () => {
|
||||
const f = await fixture();
|
||||
expect((await f.prepare()).instanceSecrets).toEqual({});
|
||||
await f.commit({ mount_grants: value });
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(companySecrets)
|
||||
.where(eq(companySecrets.ownerProjectId, f.project.id));
|
||||
expect(row).toMatchObject({
|
||||
scope: "instance",
|
||||
ownerAgentId: null,
|
||||
ownerPluginId: pluginId,
|
||||
latestVersion: 1,
|
||||
provider: "local_encrypted",
|
||||
});
|
||||
const versions = await db
|
||||
.select()
|
||||
.from(companySecretVersions)
|
||||
.where(eq(companySecretVersions.secretId, row.id));
|
||||
expect(versions).toHaveLength(1);
|
||||
expect(JSON.stringify(versions)).not.toContain(value);
|
||||
expect(
|
||||
await projectInstanceSecretService(
|
||||
createDb(database.connectionString),
|
||||
pluginId,
|
||||
).read(companyId, f.project.id),
|
||||
).toEqual({ mount_grants: value });
|
||||
const native = secretService(db);
|
||||
expect(
|
||||
await native.projectInstance.read({
|
||||
companyId: randomUUID(),
|
||||
projectId: f.project.id,
|
||||
pluginId,
|
||||
}),
|
||||
).toEqual({});
|
||||
expect(
|
||||
await native.projectInstance.read({
|
||||
companyId,
|
||||
projectId: randomUUID(),
|
||||
pluginId,
|
||||
}),
|
||||
).toEqual({});
|
||||
expect(
|
||||
await native.projectInstance.read({
|
||||
companyId,
|
||||
projectId: f.project.id,
|
||||
pluginId: randomUUID(),
|
||||
}),
|
||||
).toEqual({});
|
||||
expect(
|
||||
await native.agentInstance.read({
|
||||
companyId,
|
||||
agentId: f.project.id,
|
||||
pluginId,
|
||||
}),
|
||||
).toEqual({});
|
||||
expect(JSON.stringify(await native.list(companyId))).not.toContain(
|
||||
row.id,
|
||||
);
|
||||
const logs = await db
|
||||
.select()
|
||||
.from(activityLog)
|
||||
.where(eq(activityLog.entityId, f.project.id));
|
||||
expect(logs).toHaveLength(1);
|
||||
expect(logs[0].details).toEqual({ key: "mount_grants" });
|
||||
expect(JSON.stringify(logs)).not.toContain(value);
|
||||
expect(JSON.stringify(await f.inbox.list(companyId, 100))).not.toContain(
|
||||
value,
|
||||
);
|
||||
await expect(
|
||||
native.resolveSecretValue(companyId, row.id, "latest"),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
it("commits credentials before publication and acknowledgment; duplicate late replies cannot overwrite them", async () => {
|
||||
const f = await fixture();
|
||||
const before = await f.prepare();
|
||||
const published = vi
|
||||
.spyOn(activity, "publishActivity")
|
||||
.mockImplementation(() => {});
|
||||
try {
|
||||
await f.inbox.acknowledge(companyId, f.event.id, {
|
||||
snapshot: before.snapshot,
|
||||
changes: { mount_grants: value },
|
||||
});
|
||||
expect(published).toHaveBeenCalledTimes(1);
|
||||
expect(await f.service.read(companyId, f.project.id)).toEqual({
|
||||
mount_grants: value,
|
||||
});
|
||||
await f.inbox.acknowledge(companyId, f.event.id, {
|
||||
snapshot: before.snapshot,
|
||||
changes: { mount_grants: "late" },
|
||||
});
|
||||
expect(await f.service.read(companyId, f.project.id)).toEqual({
|
||||
mount_grants: value,
|
||||
});
|
||||
expect(published).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
published.mockRestore();
|
||||
}
|
||||
});
|
||||
it("rolls back partial native writes and acknowledgment on provider failure, then safely retries", async () => {
|
||||
const f = await fixture(),
|
||||
before = await f.prepare();
|
||||
const prepare = localEncryptedProvider.createSecret.bind(
|
||||
localEncryptedProvider,
|
||||
);
|
||||
const spy = vi
|
||||
.spyOn(localEncryptedProvider, "createSecret")
|
||||
.mockImplementationOnce(prepare)
|
||||
.mockRejectedValueOnce(new Error(value));
|
||||
try {
|
||||
await expect(
|
||||
f.inbox.acknowledge(companyId, f.event.id, {
|
||||
snapshot: before.snapshot,
|
||||
changes: { first: value, second: value },
|
||||
}),
|
||||
).rejects.toThrow("Project instance secret storage unavailable");
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
expect(await f.service.read(companyId, f.project.id)).toEqual({});
|
||||
expect(
|
||||
(await f.inbox.list(companyId, 100)).some((e) => e.id === f.event.id),
|
||||
).toBe(true);
|
||||
expect(
|
||||
await db
|
||||
.select()
|
||||
.from(activityLog)
|
||||
.where(eq(activityLog.entityId, f.project.id)),
|
||||
).toEqual([]);
|
||||
await f.commit({ mount_grants: value });
|
||||
expect(await f.service.read(companyId, f.project.id)).toEqual({
|
||||
mount_grants: value,
|
||||
});
|
||||
});
|
||||
for (const change of [
|
||||
"project",
|
||||
"workspace",
|
||||
"configuration",
|
||||
"disabled",
|
||||
"secret-version",
|
||||
] as const)
|
||||
it(`rejects stale callback after ${change} changes`, async () => {
|
||||
const f = await fixture(),
|
||||
before = await f.prepare();
|
||||
try {
|
||||
if (change === "project")
|
||||
await db
|
||||
.update(projects)
|
||||
.set({ name: "Changed" })
|
||||
.where(eq(projects.id, f.project.id));
|
||||
if (change === "workspace")
|
||||
await db.insert(projectWorkspaces).values({
|
||||
companyId,
|
||||
projectId: f.project.id,
|
||||
name: "Source",
|
||||
repoUrl: "https://github.com/fixture/project",
|
||||
});
|
||||
if (change === "configuration")
|
||||
await db
|
||||
.update(pluginConfig)
|
||||
.set({ configJson: { changed: true } })
|
||||
.where(eq(pluginConfig.pluginId, pluginId));
|
||||
if (change === "disabled")
|
||||
await db
|
||||
.insert(pluginCompanySettings)
|
||||
.values({ pluginId, companyId, enabled: false });
|
||||
if (change === "secret-version")
|
||||
await secretService(db).projectInstance.set(
|
||||
{ companyId, projectId: f.project.id, pluginId },
|
||||
"mount_grants",
|
||||
"newer",
|
||||
);
|
||||
await expect(
|
||||
f.inbox.acknowledge(companyId, f.event.id, {
|
||||
snapshot: before.snapshot,
|
||||
changes: { mount_grants: value },
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
expect(
|
||||
await db
|
||||
.select()
|
||||
.from(pluginLifecycleAcknowledgments)
|
||||
.where(
|
||||
and(
|
||||
eq(pluginLifecycleAcknowledgments.pluginId, pluginId),
|
||||
eq(
|
||||
pluginLifecycleAcknowledgments.eventId,
|
||||
Number(f.event.id),
|
||||
),
|
||||
),
|
||||
),
|
||||
).toEqual([]);
|
||||
} finally {
|
||||
if (change === "disabled")
|
||||
await db
|
||||
.delete(pluginCompanySettings)
|
||||
.where(eq(pluginCompanySettings.pluginId, pluginId));
|
||||
if (change === "configuration")
|
||||
await db
|
||||
.update(pluginConfig)
|
||||
.set({ configJson: {} })
|
||||
.where(eq(pluginConfig.pluginId, pluginId));
|
||||
}
|
||||
});
|
||||
it("rotates native versions, removes credentials without an encryption key, and cascades project deletion", async () => {
|
||||
const f = await fixture();
|
||||
const native = secretService(db);
|
||||
const scope = { companyId, projectId: f.project.id, pluginId };
|
||||
await native.projectInstance.set(scope, "mount_grants", value);
|
||||
await native.projectInstance.set(scope, "mount_grants", "replacement");
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(companySecrets)
|
||||
.where(eq(companySecrets.ownerProjectId, f.project.id));
|
||||
expect(row.latestVersion).toBe(2);
|
||||
const key = process.env.PAPERCLIP_SECRETS_MASTER_KEY!;
|
||||
vi.stubEnv("PAPERCLIP_SECRETS_MASTER_KEY", "invalid");
|
||||
try {
|
||||
await native.projectInstance.set(scope, "mount_grants", null);
|
||||
} finally {
|
||||
vi.stubEnv("PAPERCLIP_SECRETS_MASTER_KEY", key);
|
||||
}
|
||||
expect(await native.projectInstance.read(scope)).toEqual({});
|
||||
await native.projectInstance.set(scope, "mount_grants", value);
|
||||
await db.delete(projects).where(eq(projects.id, f.project.id));
|
||||
expect(await native.projectInstance.read(scope)).toEqual({});
|
||||
expect(
|
||||
await db
|
||||
.select()
|
||||
.from(companySecretVersions)
|
||||
.where(eq(companySecretVersions.secretId, row.id)),
|
||||
).toEqual([]);
|
||||
const deleted = await f.prepare();
|
||||
await expect(
|
||||
f.inbox.acknowledge(companyId, f.event.id, {
|
||||
snapshot: deleted.snapshot,
|
||||
changes: { mount_grants: value },
|
||||
}),
|
||||
).rejects.toThrow("active project");
|
||||
await f.inbox.acknowledge(companyId, f.event.id, {
|
||||
snapshot: deleted.snapshot,
|
||||
changes: { mount_grants: null },
|
||||
});
|
||||
});
|
||||
it("can acknowledge archival removal when the encryption key is unavailable", async () => {
|
||||
const f = await fixture();
|
||||
await secretService(db).projectInstance.set(
|
||||
{ companyId, projectId: f.project.id, pluginId },
|
||||
"mount_grants",
|
||||
value,
|
||||
);
|
||||
await db
|
||||
.update(projects)
|
||||
.set({ archivedAt: new Date("2020-01-01") })
|
||||
.where(eq(projects.id, f.project.id));
|
||||
const key = process.env.PAPERCLIP_SECRETS_MASTER_KEY!;
|
||||
vi.stubEnv("PAPERCLIP_SECRETS_MASTER_KEY", "invalid");
|
||||
try {
|
||||
const prepared = await f.prepare();
|
||||
expect(prepared.instanceSecrets).toEqual({});
|
||||
await f.inbox.acknowledge(companyId, f.event.id, {
|
||||
snapshot: prepared.snapshot,
|
||||
changes: { mount_grants: null },
|
||||
});
|
||||
expect(
|
||||
await db
|
||||
.select()
|
||||
.from(companySecrets)
|
||||
.where(eq(companySecrets.ownerProjectId, f.project.id)),
|
||||
).toEqual([]);
|
||||
} finally {
|
||||
vi.stubEnv("PAPERCLIP_SECRETS_MASTER_KEY", key);
|
||||
}
|
||||
});
|
||||
it("enforces project ownership in the database and rejects archived reads and new credentials", async () => {
|
||||
const f = await fixture();
|
||||
const otherCompany = randomUUID();
|
||||
await db
|
||||
.insert(companies)
|
||||
.values({
|
||||
id: otherCompany,
|
||||
name: "Other fixture",
|
||||
issuePrefix: "PCO",
|
||||
});
|
||||
await expect(
|
||||
db
|
||||
.insert(companySecrets)
|
||||
.values({
|
||||
companyId: otherCompany,
|
||||
scope: "instance",
|
||||
ownerProjectId: f.project.id,
|
||||
ownerPluginId: pluginId,
|
||||
key: "CREDENTIAL",
|
||||
name: "credential",
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
await expect(
|
||||
db
|
||||
.insert(companySecrets)
|
||||
.values({
|
||||
companyId,
|
||||
scope: "company",
|
||||
ownerProjectId: f.project.id,
|
||||
key: "INVALID",
|
||||
name: "invalid",
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
await db
|
||||
.update(projects)
|
||||
.set({ archivedAt: new Date("2020-01-01") })
|
||||
.where(eq(projects.id, f.project.id));
|
||||
await expect(f.service.read(companyId, f.project.id)).rejects.toThrow(
|
||||
"unavailable",
|
||||
);
|
||||
const { snapshot } = await f.prepare();
|
||||
await expect(
|
||||
f.inbox.acknowledge(companyId, f.event.id, {
|
||||
snapshot,
|
||||
changes: { mount_grants: value },
|
||||
}),
|
||||
).rejects.toThrow("active project");
|
||||
await f.inbox.acknowledge(companyId, f.event.id, {
|
||||
snapshot,
|
||||
changes: { mount_grants: null },
|
||||
});
|
||||
});
|
||||
it("preserves account-home protection for project-owned credentials", async () => {
|
||||
const f = await fixture(),
|
||||
native = secretService(db),
|
||||
accountHome = "/fixture/account-home";
|
||||
await native.projectInstance.set(
|
||||
{ companyId, projectId: f.project.id, pluginId },
|
||||
"home",
|
||||
accountHome,
|
||||
);
|
||||
expect(
|
||||
await native.hasInstanceAccountHomeClaim(companyId, accountHome),
|
||||
).toBe(true);
|
||||
expect(
|
||||
await native.hasInstanceAccountHomeClaim(
|
||||
companyId,
|
||||
accountHome + "-other",
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
},
|
||||
);
|
||||
describe("project instance secret patches", () => {
|
||||
it("accepts bounded bundles and removal and rejects malformed patches without leaking values", () => {
|
||||
expect(() =>
|
||||
validateProjectInstanceSecretChanges({
|
||||
mount_grants: "x".repeat(131_072),
|
||||
old: null,
|
||||
}),
|
||||
).not.toThrow();
|
||||
for (const changes of [
|
||||
null,
|
||||
[],
|
||||
{ "bad/key": "private" },
|
||||
{ mount_grants: "" },
|
||||
{ mount_grants: " " },
|
||||
{ mount_grants: 1 },
|
||||
{ mount_grants: "x".repeat(131_073) },
|
||||
Object.fromEntries(
|
||||
Array.from({ length: 17 }, (_, i) => [`key_${i}`, "private"]),
|
||||
),
|
||||
]) {
|
||||
expect(() => validateProjectInstanceSecretChanges(changes)).toThrow(
|
||||
"Invalid project instance secret changes",
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -106,7 +106,7 @@ describeEmbeddedPostgres("project list archived route defaults", () => {
|
||||
const res = await request(app).get(`/api/companies/${companyId}/projects?includeArchived=true`);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.map((project: { id: string }) => project.id)).toEqual([activeProjectId, archivedProjectId]);
|
||||
expect(res.body.map((project: { id: string }) => project.id).sort()).toEqual([activeProjectId, archivedProjectId].sort());
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -47,6 +47,7 @@ const OPERATION_CAPABILITIES: Record<string, readonly PluginCapability[]> = {
|
||||
"companies.get": ["companies.read"],
|
||||
"projects.list": ["projects.read"],
|
||||
"projects.get": ["projects.read"],
|
||||
"projects.getInstanceSecrets": ["projects.instance-secrets"],
|
||||
"projects.managed.get": ["projects.managed"],
|
||||
"projects.managed.reconcile": ["projects.managed"],
|
||||
"projects.managed.reset": ["projects.managed"],
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { projectInstanceSecretService } from "./project-instance-secrets.js";
|
||||
import { pluginAgentRuntimeState } from "./plugin-agent-runtime-state.js";
|
||||
import { createAgentLifecycle } from "./agent-lifecycle.js";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
@@ -1862,6 +1863,11 @@ export function buildHostServices(
|
||||
},
|
||||
|
||||
projects: {
|
||||
async getInstanceSecrets(params) {
|
||||
const companyId = ensureCompanyId(params.companyId);
|
||||
await ensurePluginAvailableForCompany(companyId);
|
||||
return projectInstanceSecretService(db, pluginId).read(companyId, params.projectId);
|
||||
},
|
||||
async list(params) {
|
||||
const companyId = ensureCompanyId(params.companyId);
|
||||
await ensurePluginAvailableForCompany(companyId);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { projectInstanceSecretService } from "./project-instance-secrets.js";
|
||||
import { and, asc, eq, isNull, or, sql } from "drizzle-orm";
|
||||
import { pluginCompanySettings, pluginConfig, plugins, type Db } from "@paperclipai/db";
|
||||
import { logger } from "../middleware/logger.js";
|
||||
@@ -74,8 +75,11 @@ export function registerPluginLifecycleDelivery(
|
||||
.where(and(eq(plugins.id, plugin.id), eq(plugins.status, "ready"),
|
||||
or(isNull(pluginCompanySettings.enabled), eq(pluginCompanySettings.enabled, true))));
|
||||
if (!enabled?.manifest.resourceLifecycle?.includes(event.resourceType) || !enabled.manifest.capabilities.includes("events.subscribe")) break;
|
||||
stage = "credentials";
|
||||
const credentials = event.resourceType === "project" && enabled.manifest.capabilities.includes("projects.instance-secrets")
|
||||
? await projectInstanceSecretService(db, plugin.id).prepare(event) : undefined;
|
||||
stage = "callback";
|
||||
const result = await manager.call(plugin.id, "resourceLifecycle", event, 30_000);
|
||||
const result = await manager.call(plugin.id, "resourceLifecycle", credentials ? { ...event, instanceSecrets: credentials.instanceSecrets } : event, 30_000);
|
||||
stage = "result";
|
||||
if (result?.eventId !== event.id || !["acknowledged", "retry", "deferred"].includes(result.status)) {
|
||||
throw new Error("Invalid resource lifecycle result");
|
||||
@@ -83,7 +87,8 @@ export function registerPluginLifecycleDelivery(
|
||||
if (signal.aborted) return;
|
||||
if (result.status === "acknowledged") {
|
||||
stage = "acknowledgment";
|
||||
await inbox.acknowledge(companyId, event.id);
|
||||
if (result.instanceSecrets !== undefined && !credentials) throw new Error("Project instance secrets outside callback scope");
|
||||
await inbox.acknowledge(companyId, event.id, credentials ? { snapshot: credentials.snapshot, changes: result.instanceSecrets ?? {} } : undefined);
|
||||
// An acknowledgment exposes the next event for this resource. Drain immediately.
|
||||
wake();
|
||||
} else if (result.status === "retry") {
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import { withAccountHomeSecretMutationLock } from "@paperclipai/adapter-codex-local/server";
|
||||
import { projectInstanceSecretService, type ProjectSecretSnapshot } from "./project-instance-secrets.js";
|
||||
import { publishActivity, type ActivityPublication } from "./activity-log.js";
|
||||
import { pluginCompanySettings, pluginLifecycleAcknowledgments, plugins, resourceLifecycleEvents, type Db } from "@paperclipai/db";
|
||||
import type { ResourceLifecycleEvent } from "@paperclipai/plugin-sdk";
|
||||
import { z } from "zod";
|
||||
@@ -40,19 +43,29 @@ export function pluginLifecycleInbox(db: Db, pluginId: string) {
|
||||
.orderBy(asc(resourceLifecycleEvents.id)).limit(limit);
|
||||
return rows.map(row => ({ ...row, id: String(row.id), createdAt: row.createdAt.toISOString() })) as ResourceLifecycleEvent[];
|
||||
},
|
||||
async acknowledge(companyId: string, eventId: string): Promise<void> {
|
||||
async acknowledge(companyId: string, eventId: string, credentials?: { snapshot: ProjectSecretSnapshot; changes: unknown }): Promise<void> {
|
||||
if (typeof eventId !== "string" || !/^[1-9]\d*$/.test(eventId) || !Number.isSafeInteger(Number(eventId))) throw badRequest("Invalid lifecycle event id");
|
||||
await db.transaction(async tx => {
|
||||
const commit = () => db.transaction(async tx => {
|
||||
const publications: ActivityPublication[] = [];
|
||||
const txDb = tx as unknown as Db;
|
||||
await assertAvailable(txDb, companyId);
|
||||
const [event] = await tx.select().from(resourceLifecycleEvents)
|
||||
.where(and(eq(resourceLifecycleEvents.companyId, companyId), eq(resourceLifecycleEvents.id, Number(eventId)))).for("update");
|
||||
if (!event) throw notFound("Lifecycle event not found");
|
||||
const [acked] = await tx.select().from(pluginLifecycleAcknowledgments).where(and(eq(pluginLifecycleAcknowledgments.pluginId, pluginId), eq(pluginLifecycleAcknowledgments.eventId, event.id)));
|
||||
if (acked) return publications;
|
||||
const [eligible] = await tx.select({ id: resourceLifecycleEvents.id }).from(resourceLifecycleEvents)
|
||||
.where(and(eq(resourceLifecycleEvents.id, event.id), firstForResource));
|
||||
if (!eligible) throw conflict("Acknowledge earlier lifecycle events for this resource first");
|
||||
if (credentials) {
|
||||
if (event.resourceType !== "project") throw forbidden("Project instance secret event required");
|
||||
publications.push(...await projectInstanceSecretService(txDb, pluginId).apply({ ...event, id: String(event.id), createdAt: event.createdAt.toISOString() } as ResourceLifecycleEvent, credentials.snapshot, credentials.changes));
|
||||
}
|
||||
await tx.insert(pluginLifecycleAcknowledgments).values({ pluginId, eventId: event.id }).onConflictDoNothing();
|
||||
return publications;
|
||||
});
|
||||
const publications = credentials ? await withAccountHomeSecretMutationLock(undefined, companyId, commit) : await commit();
|
||||
for (const publication of publications) publishActivity(publication);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { and, asc, desc, eq, ne } from "drizzle-orm";
|
||||
import {
|
||||
companySecrets,
|
||||
pluginCompanySettings,
|
||||
pluginConfig,
|
||||
plugins,
|
||||
projects,
|
||||
projectWorkspaces,
|
||||
resourceLifecycleEvents,
|
||||
type Db,
|
||||
} from "@paperclipai/db";
|
||||
import type { ResourceLifecycleEvent } from "@paperclipai/plugin-sdk";
|
||||
import { z } from "zod";
|
||||
import { secretService } from "./secrets.js";
|
||||
import { persistActivity, type ActivityPublication } from "./activity-log.js";
|
||||
|
||||
export type ProjectSecretSnapshot = { revision: string };
|
||||
export function validateProjectInstanceSecretChanges(
|
||||
changes: unknown,
|
||||
): asserts changes is Record<string, string | null> {
|
||||
if (!changes || typeof changes !== "object" || Array.isArray(changes))
|
||||
throw new Error("Invalid project instance secret changes");
|
||||
const entries = Object.entries(changes);
|
||||
if (
|
||||
entries.length > 16 ||
|
||||
entries.some(
|
||||
([key, value]) =>
|
||||
!/^[a-z][a-z0-9_]{0,63}$/.test(key) ||
|
||||
(value !== null &&
|
||||
(typeof value !== "string" ||
|
||||
!value.trim() ||
|
||||
Buffer.byteLength(value) > 131_072)),
|
||||
)
|
||||
) {
|
||||
throw new Error("Invalid project instance secret changes");
|
||||
}
|
||||
}
|
||||
|
||||
/** Reuses native instance credentials; values never enter the lifecycle journal or plugin entities. */
|
||||
export function projectInstanceSecretService(db: Db, pluginId: string) {
|
||||
async function state(companyId: string, projectId: string, lock = false) {
|
||||
if (
|
||||
![companyId, projectId].every(
|
||||
(id) => z.string().uuid().safeParse(id).success,
|
||||
)
|
||||
)
|
||||
throw new Error("Invalid project instance secret scope");
|
||||
const pluginQuery = db
|
||||
.select()
|
||||
.from(plugins)
|
||||
.where(eq(plugins.id, pluginId));
|
||||
const [plugin] = await (lock ? pluginQuery.for("share") : pluginQuery);
|
||||
const settingsQuery = db
|
||||
.select()
|
||||
.from(pluginCompanySettings)
|
||||
.where(
|
||||
and(
|
||||
eq(pluginCompanySettings.pluginId, pluginId),
|
||||
eq(pluginCompanySettings.companyId, companyId),
|
||||
),
|
||||
);
|
||||
const [settings] = await (lock
|
||||
? settingsQuery.for("share")
|
||||
: settingsQuery);
|
||||
if (
|
||||
plugin?.status !== "ready" ||
|
||||
settings?.enabled === false ||
|
||||
!plugin.manifestJson.capabilities.includes("projects.instance-secrets") ||
|
||||
!plugin.manifestJson.capabilities.includes("events.subscribe") ||
|
||||
!plugin.manifestJson.resourceLifecycle?.includes("project")
|
||||
) {
|
||||
throw new Error("Project instance secrets outside plugin scope");
|
||||
}
|
||||
const configQuery = db
|
||||
.select()
|
||||
.from(pluginConfig)
|
||||
.where(
|
||||
and(
|
||||
eq(pluginConfig.pluginId, pluginId),
|
||||
eq(pluginConfig.companyId, companyId),
|
||||
),
|
||||
);
|
||||
const [config] = await (lock ? configQuery.for("share") : configQuery);
|
||||
if (!config)
|
||||
throw new Error("Project instance secrets require plugin configuration");
|
||||
const projectQuery = db
|
||||
.select()
|
||||
.from(projects)
|
||||
.where(
|
||||
and(eq(projects.companyId, companyId), eq(projects.id, projectId)),
|
||||
);
|
||||
const [project] = await (lock ? projectQuery.for("update") : projectQuery);
|
||||
const workspaces = await db
|
||||
.select()
|
||||
.from(projectWorkspaces)
|
||||
.where(
|
||||
and(
|
||||
eq(projectWorkspaces.companyId, companyId),
|
||||
eq(projectWorkspaces.projectId, projectId),
|
||||
),
|
||||
)
|
||||
.orderBy(asc(projectWorkspaces.id));
|
||||
const [latestEvent] = await db
|
||||
.select({ id: resourceLifecycleEvents.id })
|
||||
.from(resourceLifecycleEvents)
|
||||
.where(
|
||||
and(
|
||||
eq(resourceLifecycleEvents.companyId, companyId),
|
||||
eq(resourceLifecycleEvents.resourceType, "project"),
|
||||
eq(resourceLifecycleEvents.resourceId, projectId),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(resourceLifecycleEvents.id))
|
||||
.limit(1);
|
||||
const secrets = await db
|
||||
.select({
|
||||
id: companySecrets.id,
|
||||
version: companySecrets.latestVersion,
|
||||
status: companySecrets.status,
|
||||
})
|
||||
.from(companySecrets)
|
||||
.where(
|
||||
and(
|
||||
eq(companySecrets.companyId, companyId),
|
||||
eq(companySecrets.scope, "instance"),
|
||||
eq(companySecrets.ownerProjectId, projectId),
|
||||
eq(companySecrets.ownerPluginId, pluginId),
|
||||
ne(companySecrets.status, "deleted"),
|
||||
),
|
||||
)
|
||||
.orderBy(asc(companySecrets.id));
|
||||
return {
|
||||
project,
|
||||
revision: createHash("sha256")
|
||||
.update(
|
||||
JSON.stringify([
|
||||
plugin.updatedAt,
|
||||
plugin.manifestJson,
|
||||
settings,
|
||||
config,
|
||||
project,
|
||||
workspaces,
|
||||
latestEvent,
|
||||
secrets,
|
||||
]),
|
||||
)
|
||||
.digest("hex"),
|
||||
};
|
||||
}
|
||||
async function read(companyId: string, projectId: string) {
|
||||
try {
|
||||
return await secretService(db).projectInstance.read({
|
||||
companyId,
|
||||
projectId,
|
||||
pluginId,
|
||||
});
|
||||
} catch {
|
||||
throw new Error("Project instance secret unavailable");
|
||||
}
|
||||
}
|
||||
return {
|
||||
async read(companyId: string, projectId: string) {
|
||||
const current = await state(companyId, projectId);
|
||||
if (!current.project || current.project.archivedAt)
|
||||
throw new Error("Project instance secrets unavailable");
|
||||
return read(companyId, projectId);
|
||||
},
|
||||
async prepare(event: ResourceLifecycleEvent) {
|
||||
if (event.resourceType !== "project")
|
||||
throw new Error("Project instance secret event required");
|
||||
const snapshot = await state(event.companyId, event.resourceId);
|
||||
return {
|
||||
snapshot: { revision: snapshot.revision },
|
||||
instanceSecrets:
|
||||
snapshot.project && !snapshot.project.archivedAt
|
||||
? await read(event.companyId, event.resourceId)
|
||||
: {},
|
||||
};
|
||||
},
|
||||
/** Caller holds the event row and account-home mutation lock, in the acknowledgment transaction. */
|
||||
async apply(
|
||||
event: ResourceLifecycleEvent,
|
||||
snapshot: ProjectSecretSnapshot,
|
||||
changes: unknown,
|
||||
): Promise<ActivityPublication[]> {
|
||||
validateProjectInstanceSecretChanges(changes);
|
||||
const current = await state(event.companyId, event.resourceId, true);
|
||||
if (snapshot.revision !== current.revision)
|
||||
throw new Error("Project instance secret operation is stale");
|
||||
if (
|
||||
(!current.project || current.project.archivedAt) &&
|
||||
Object.values(changes).some((value) => value !== null)
|
||||
) {
|
||||
throw new Error("Project instance secrets require an active project");
|
||||
}
|
||||
const publications: ActivityPublication[] = [];
|
||||
for (const [key, value] of Object.entries(changes)) {
|
||||
try {
|
||||
await secretService(db).projectInstance.set(
|
||||
{
|
||||
companyId: event.companyId,
|
||||
projectId: event.resourceId,
|
||||
pluginId,
|
||||
},
|
||||
key,
|
||||
value,
|
||||
);
|
||||
} catch {
|
||||
throw new Error("Project instance secret storage unavailable");
|
||||
}
|
||||
const { publication } = await persistActivity(db, {
|
||||
companyId: event.companyId,
|
||||
actorType: "plugin",
|
||||
actorId: pluginId,
|
||||
action:
|
||||
value === null
|
||||
? "project.instance_secret.removed"
|
||||
: "project.instance_secret.saved",
|
||||
entityType: "project",
|
||||
entityId: event.resourceId,
|
||||
details: { key },
|
||||
});
|
||||
publications.push(publication);
|
||||
}
|
||||
return publications;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -644,11 +644,14 @@ type SecretBindingContext = Omit<SecretConsumerContext, "consumerType"> & {
|
||||
consumerType: SecretBindingTargetType;
|
||||
};
|
||||
|
||||
type InstanceSecretOwner = { pluginId: string } & ({ agentId: string; projectId?: never } | { projectId: string; agentId?: never });
|
||||
type InstanceSecretScope = InstanceSecretOwner & { companyId: string };
|
||||
|
||||
type SecretResolutionOptions = {
|
||||
bindingContext?: SecretBindingContext;
|
||||
accessContext?: SecretConsumerContext;
|
||||
allowUserSecretScope?: boolean;
|
||||
instanceOwner?: { agentId: string; pluginId: string };
|
||||
instanceOwner?: InstanceSecretOwner;
|
||||
};
|
||||
|
||||
export type AgentSecretReadContext = {
|
||||
@@ -1362,7 +1365,8 @@ export function secretService(db: Db | DbTransaction) {
|
||||
if (!secret) throw notFound("Secret not found");
|
||||
if (secret.companyId !== companyId) throw unprocessable("Secret must belong to same company");
|
||||
const instanceOwned = secret.scope === "instance" && options?.instanceOwner &&
|
||||
secret.ownerAgentId === options.instanceOwner.agentId && secret.ownerPluginId === options.instanceOwner.pluginId;
|
||||
secret.ownerAgentId === (options.instanceOwner.agentId ?? null) &&
|
||||
secret.ownerProjectId === (options.instanceOwner.projectId ?? null) && secret.ownerPluginId === options.instanceOwner.pluginId;
|
||||
if (secret.scope !== "company" && !(secret.scope === "user" && options?.allowUserSecretScope) && !instanceOwned) {
|
||||
throw unprocessable("User-scoped secrets must be resolved through user secret declarations", {
|
||||
code: "secret_scope_invalid",
|
||||
@@ -2188,7 +2192,7 @@ export function secretService(db: Db | DbTransaction) {
|
||||
providerMetadata?: Record<string, unknown> | null;
|
||||
},
|
||||
actor?: { userId?: string | null; agentId?: string | null },
|
||||
instanceOwner?: { agentId: string; pluginId: string },
|
||||
instanceOwner?: InstanceSecretOwner,
|
||||
) {
|
||||
const existing = instanceOwner ? null : await getByName(companyId, input.name);
|
||||
if (existing) throw conflict(`Secret already exists: ${input.name}`);
|
||||
@@ -2234,6 +2238,7 @@ export function secretService(db: Db | DbTransaction) {
|
||||
companyId,
|
||||
scope: instanceOwner ? "instance" : "company",
|
||||
ownerAgentId: instanceOwner?.agentId ?? null,
|
||||
ownerProjectId: instanceOwner?.projectId ?? null,
|
||||
ownerPluginId: instanceOwner?.pluginId ?? null,
|
||||
key,
|
||||
name: input.name,
|
||||
@@ -3488,6 +3493,39 @@ export function secretService(db: Db | DbTransaction) {
|
||||
.then((rows) => rows[0] ?? null);
|
||||
}
|
||||
|
||||
const instanceSecrets = {
|
||||
async read(scope: InstanceSecretScope): Promise<Record<string, string>> {
|
||||
const rows = await db.select().from(companySecrets).where(and(eq(companySecrets.companyId, scope.companyId),
|
||||
eq(companySecrets.scope, "instance"), scope.agentId !== undefined ? eq(companySecrets.ownerAgentId, scope.agentId) : eq(companySecrets.ownerProjectId, scope.projectId),
|
||||
eq(companySecrets.ownerPluginId, scope.pluginId), ne(companySecrets.status, "deleted")));
|
||||
const values: Record<string, string> = Object.create(null);
|
||||
for (const row of rows) {
|
||||
const resolved = await resolveSecretValueInternal(scope.companyId, row.id, "latest", {
|
||||
instanceOwner: scope, accessContext: { consumerType: "plugin_worker", consumerId: scope.pluginId,
|
||||
actorType: "plugin", actorId: scope.pluginId, pluginId: scope.pluginId },
|
||||
});
|
||||
values[row.name] = resolved.value;
|
||||
}
|
||||
return values;
|
||||
},
|
||||
async set(scope: InstanceSecretScope, key: string, value: string | null) {
|
||||
return withAccountHomeSecretMutationLock(undefined, scope.companyId, async () => {
|
||||
if (value) await assertAccountHomeCacheDirStillValid(undefined, scope.companyId, value);
|
||||
const normalizedKey = normalizeSecretKey(key);
|
||||
const [existing] = await db.select().from(companySecrets).where(and(eq(companySecrets.companyId, scope.companyId),
|
||||
eq(companySecrets.scope, "instance"), scope.agentId !== undefined ? eq(companySecrets.ownerAgentId, scope.agentId) : eq(companySecrets.ownerProjectId, scope.projectId),
|
||||
eq(companySecrets.ownerPluginId, scope.pluginId), eq(companySecrets.key, normalizedKey), ne(companySecrets.status, "deleted")));
|
||||
if (value === null) {
|
||||
if (existing) await removeSecretUnlocked(existing.id);
|
||||
} else if (existing) {
|
||||
await rotateUnlocked(existing.id, { value, expectedLatestVersion: existing.latestVersion });
|
||||
} else {
|
||||
await createSecretUnlocked(scope.companyId, { name: key, key, provider: "local_encrypted", value }, undefined, scope);
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
listProviders: () => listSecretProviders(),
|
||||
|
||||
@@ -3496,9 +3534,9 @@ export function secretService(db: Db | DbTransaction) {
|
||||
const rows = await db.select().from(companySecrets).where(and(eq(companySecrets.companyId, companyId),
|
||||
eq(companySecrets.scope, "instance"), ne(companySecrets.status, "deleted")));
|
||||
for (const row of rows) {
|
||||
if (!row.ownerAgentId || !row.ownerPluginId) return true;
|
||||
if ((!row.ownerAgentId && !row.ownerProjectId) || !row.ownerPluginId) return true;
|
||||
const resolved = await resolveSecretValueInternal(companyId, row.id, "latest", {
|
||||
instanceOwner: { agentId: row.ownerAgentId, pluginId: row.ownerPluginId },
|
||||
instanceOwner: row.ownerAgentId ? { agentId: row.ownerAgentId, pluginId: row.ownerPluginId } : { projectId: row.ownerProjectId!, pluginId: row.ownerPluginId },
|
||||
accessContext: { consumerType: "system", consumerId: DEVICE_LOGIN_SECRET_CHECK_CONSUMER_ID,
|
||||
actorType: "system", actorId: null },
|
||||
});
|
||||
@@ -3508,38 +3546,8 @@ export function secretService(db: Db | DbTransaction) {
|
||||
} catch { return true; }
|
||||
},
|
||||
|
||||
agentInstance: {
|
||||
async read(scope: { companyId: string; agentId: string; pluginId: string }): Promise<Record<string, string>> {
|
||||
const rows = await db.select().from(companySecrets).where(and(eq(companySecrets.companyId, scope.companyId),
|
||||
eq(companySecrets.scope, "instance"), eq(companySecrets.ownerAgentId, scope.agentId),
|
||||
eq(companySecrets.ownerPluginId, scope.pluginId), ne(companySecrets.status, "deleted")));
|
||||
const values: Record<string, string> = Object.create(null);
|
||||
for (const row of rows) {
|
||||
const resolved = await resolveSecretValueInternal(scope.companyId, row.id, "latest", {
|
||||
instanceOwner: scope, accessContext: { consumerType: "plugin_worker", consumerId: scope.pluginId,
|
||||
actorType: "plugin", actorId: scope.pluginId, pluginId: scope.pluginId },
|
||||
});
|
||||
values[row.name] = resolved.value;
|
||||
}
|
||||
return values;
|
||||
},
|
||||
async set(scope: { companyId: string; agentId: string; pluginId: string }, key: string, value: string | null) {
|
||||
return withAccountHomeSecretMutationLock(undefined, scope.companyId, async () => {
|
||||
if (value) await assertAccountHomeCacheDirStillValid(undefined, scope.companyId, value);
|
||||
const normalizedKey = normalizeSecretKey(key);
|
||||
const [existing] = await db.select().from(companySecrets).where(and(eq(companySecrets.companyId, scope.companyId),
|
||||
eq(companySecrets.scope, "instance"), eq(companySecrets.ownerAgentId, scope.agentId),
|
||||
eq(companySecrets.ownerPluginId, scope.pluginId), eq(companySecrets.key, normalizedKey), ne(companySecrets.status, "deleted")));
|
||||
if (value === null) {
|
||||
if (existing) await removeSecretUnlocked(existing.id);
|
||||
} else if (existing) {
|
||||
await rotateUnlocked(existing.id, { value, expectedLatestVersion: existing.latestVersion });
|
||||
} else {
|
||||
await createSecretUnlocked(scope.companyId, { name: key, key, provider: "local_encrypted", value }, undefined, scope);
|
||||
}
|
||||
});
|
||||
},
|
||||
},
|
||||
agentInstance: instanceSecrets,
|
||||
projectInstance: instanceSecrets,
|
||||
|
||||
checkProviders: () => checkSecretProviders(),
|
||||
|
||||
|
||||
Reference in new issue
Block a user