Extend native instance secrets to project lifecycle plugins (#17034)

This commit is contained in:
Nicky Leach authored and GitHub committed 2026-10-20 20:09:16 -07:00
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
));
File diff suppressed because it is too large. Load diff
+7
View File
@@ -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
}
]
}
+9 -3
View File
@@ -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
)`,
),
}),
+1
View File
@@ -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")
+26
View File
@@ -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);
}),
+4
View File
@@ -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,
+11
View File
@@ -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) {
+10
View File
@@ -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();
});
});
+1
View File
@@ -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") {
+15 -2
View File
@@ -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;
},
};
}
+45 -37
View File
@@ -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(),