mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 09:07:46 +02:00
Implement secrets service contract
This commit is contained in:
1 parent
0e1a582831
commit
c7fcfee836
19 files changed
+927
-22
No files matched your search
@@ -0,0 +1,124 @@
|
||||
CREATE TABLE IF NOT EXISTS "company_secret_bindings" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"company_id" uuid NOT NULL,
|
||||
"secret_id" uuid NOT NULL,
|
||||
"target_type" text NOT NULL,
|
||||
"target_id" text NOT NULL,
|
||||
"config_path" text NOT NULL,
|
||||
"version_selector" text DEFAULT 'latest' NOT NULL,
|
||||
"required" boolean DEFAULT true NOT NULL,
|
||||
"label" text,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS "secret_access_events" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"company_id" uuid NOT NULL,
|
||||
"secret_id" uuid NOT NULL,
|
||||
"version" integer,
|
||||
"provider" text NOT NULL,
|
||||
"actor_type" text NOT NULL,
|
||||
"actor_id" text,
|
||||
"consumer_type" text NOT NULL,
|
||||
"consumer_id" text NOT NULL,
|
||||
"config_path" text,
|
||||
"issue_id" uuid,
|
||||
"heartbeat_run_id" uuid,
|
||||
"plugin_id" uuid,
|
||||
"outcome" text NOT NULL,
|
||||
"error_code" text,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "company_secrets" ADD COLUMN IF NOT EXISTS "key" text;--> statement-breakpoint
|
||||
UPDATE "company_secrets"
|
||||
SET "key" = left(
|
||||
regexp_replace(
|
||||
regexp_replace(lower(trim(coalesce("name", "id"::text))), '[^a-z0-9_.-]+', '-', 'g'),
|
||||
'^-+|-+$',
|
||||
'',
|
||||
'g'
|
||||
),
|
||||
120
|
||||
)
|
||||
WHERE "key" IS NULL;--> statement-breakpoint
|
||||
UPDATE "company_secrets"
|
||||
SET "key" = "id"::text
|
||||
WHERE "key" IS NULL OR "key" = '';--> statement-breakpoint
|
||||
ALTER TABLE "company_secrets" ALTER COLUMN "key" SET NOT NULL;--> statement-breakpoint
|
||||
WITH ranked AS (
|
||||
SELECT
|
||||
"id",
|
||||
"key",
|
||||
row_number() OVER (PARTITION BY "company_id", "key" ORDER BY "created_at", "id") AS rn
|
||||
FROM "company_secrets"
|
||||
)
|
||||
UPDATE "company_secrets"
|
||||
SET "key" = left(ranked."key", 100) || '-' || ranked.rn::text
|
||||
FROM ranked
|
||||
WHERE "company_secrets"."id" = ranked."id"
|
||||
AND ranked.rn > 1;--> statement-breakpoint
|
||||
ALTER TABLE "company_secrets" ADD COLUMN IF NOT EXISTS "status" text DEFAULT 'active' NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "company_secrets" ADD COLUMN IF NOT EXISTS "managed_mode" text DEFAULT 'paperclip_managed' NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "company_secrets" ADD COLUMN IF NOT EXISTS "provider_config_id" text;--> statement-breakpoint
|
||||
ALTER TABLE "company_secrets" ADD COLUMN IF NOT EXISTS "provider_metadata" jsonb;--> statement-breakpoint
|
||||
ALTER TABLE "company_secrets" ADD COLUMN IF NOT EXISTS "last_resolved_at" timestamp with time zone;--> statement-breakpoint
|
||||
ALTER TABLE "company_secrets" ADD COLUMN IF NOT EXISTS "last_rotated_at" timestamp with time zone;--> statement-breakpoint
|
||||
UPDATE "company_secrets"
|
||||
SET "last_rotated_at" = "updated_at"
|
||||
WHERE "last_rotated_at" IS NULL;--> statement-breakpoint
|
||||
ALTER TABLE "company_secrets" ADD COLUMN IF NOT EXISTS "deleted_at" timestamp with time zone;--> statement-breakpoint
|
||||
ALTER TABLE "company_secret_versions" ADD COLUMN IF NOT EXISTS "provider_version_ref" text;--> statement-breakpoint
|
||||
ALTER TABLE "company_secret_versions" ADD COLUMN IF NOT EXISTS "status" text DEFAULT 'current' NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "company_secret_versions" ADD COLUMN IF NOT EXISTS "fingerprint_sha256" text;--> statement-breakpoint
|
||||
UPDATE "company_secret_versions"
|
||||
SET "fingerprint_sha256" = "value_sha256"
|
||||
WHERE "fingerprint_sha256" IS NULL;--> statement-breakpoint
|
||||
ALTER TABLE "company_secret_versions" ALTER COLUMN "fingerprint_sha256" SET NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "company_secret_versions" ADD COLUMN IF NOT EXISTS "rotation_job_id" text;--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'company_secret_bindings_company_id_companies_id_fk') THEN
|
||||
ALTER TABLE "company_secret_bindings" ADD CONSTRAINT "company_secret_bindings_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE no action ON UPDATE no action;
|
||||
END IF;
|
||||
END $$;--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'company_secret_bindings_secret_id_company_secrets_id_fk') THEN
|
||||
ALTER TABLE "company_secret_bindings" ADD CONSTRAINT "company_secret_bindings_secret_id_company_secrets_id_fk" FOREIGN KEY ("secret_id") REFERENCES "public"."company_secrets"("id") ON DELETE cascade ON UPDATE no action;
|
||||
END IF;
|
||||
END $$;--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'secret_access_events_company_id_companies_id_fk') THEN
|
||||
ALTER TABLE "secret_access_events" ADD CONSTRAINT "secret_access_events_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE no action ON UPDATE no action;
|
||||
END IF;
|
||||
END $$;--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'secret_access_events_secret_id_company_secrets_id_fk') THEN
|
||||
ALTER TABLE "secret_access_events" ADD CONSTRAINT "secret_access_events_secret_id_company_secrets_id_fk" FOREIGN KEY ("secret_id") REFERENCES "public"."company_secrets"("id") ON DELETE cascade ON UPDATE no action;
|
||||
END IF;
|
||||
END $$;--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'secret_access_events_issue_id_issues_id_fk') THEN
|
||||
ALTER TABLE "secret_access_events" ADD CONSTRAINT "secret_access_events_issue_id_issues_id_fk" FOREIGN KEY ("issue_id") REFERENCES "public"."issues"("id") ON DELETE set null ON UPDATE no action;
|
||||
END IF;
|
||||
END $$;--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'secret_access_events_heartbeat_run_id_heartbeat_runs_id_fk') THEN
|
||||
ALTER TABLE "secret_access_events" ADD CONSTRAINT "secret_access_events_heartbeat_run_id_heartbeat_runs_id_fk" FOREIGN KEY ("heartbeat_run_id") REFERENCES "public"."heartbeat_runs"("id") ON DELETE set null ON UPDATE no action;
|
||||
END IF;
|
||||
END $$;--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'secret_access_events_plugin_id_plugins_id_fk') THEN
|
||||
ALTER TABLE "secret_access_events" ADD CONSTRAINT "secret_access_events_plugin_id_plugins_id_fk" FOREIGN KEY ("plugin_id") REFERENCES "public"."plugins"("id") ON DELETE set null ON UPDATE no action;
|
||||
END IF;
|
||||
END $$;--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "company_secret_bindings_company_idx" ON "company_secret_bindings" USING btree ("company_id");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "company_secret_bindings_secret_idx" ON "company_secret_bindings" USING btree ("secret_id");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "company_secret_bindings_target_idx" ON "company_secret_bindings" USING btree ("company_id","target_type","target_id");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "company_secret_bindings_target_path_uq" ON "company_secret_bindings" USING btree ("company_id","target_type","target_id","config_path");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "secret_access_events_company_created_idx" ON "secret_access_events" USING btree ("company_id","created_at");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "secret_access_events_secret_created_idx" ON "secret_access_events" USING btree ("secret_id","created_at");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "secret_access_events_consumer_idx" ON "secret_access_events" USING btree ("company_id","consumer_type","consumer_id");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "secret_access_events_run_idx" ON "secret_access_events" USING btree ("heartbeat_run_id");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "company_secret_versions_fingerprint_idx" ON "company_secret_versions" USING btree ("fingerprint_sha256");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "company_secrets_company_key_uq" ON "company_secrets" USING btree ("company_id","key");
|
||||
@@ -575,6 +575,13 @@
|
||||
"when": 1778067785040,
|
||||
"tag": "0081_optimal_dormammu",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 82,
|
||||
"version": "7",
|
||||
"when": 1777239150329,
|
||||
"tag": "0082_dry_vision",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { boolean, index, pgTable, text, timestamp, uniqueIndex, uuid } from "drizzle-orm/pg-core";
|
||||
import { companies } from "./companies.js";
|
||||
import { companySecrets } from "./company_secrets.js";
|
||||
|
||||
export const companySecretBindings = pgTable(
|
||||
"company_secret_bindings",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
companyId: uuid("company_id").notNull().references(() => companies.id),
|
||||
secretId: uuid("secret_id").notNull().references(() => companySecrets.id, { onDelete: "cascade" }),
|
||||
targetType: text("target_type").notNull(),
|
||||
targetId: text("target_id").notNull(),
|
||||
configPath: text("config_path").notNull(),
|
||||
versionSelector: text("version_selector").notNull().default("latest"),
|
||||
required: boolean("required").notNull().default(true),
|
||||
label: text("label"),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => ({
|
||||
companyIdx: index("company_secret_bindings_company_idx").on(table.companyId),
|
||||
secretIdx: index("company_secret_bindings_secret_idx").on(table.secretId),
|
||||
targetIdx: index("company_secret_bindings_target_idx").on(table.companyId, table.targetType, table.targetId),
|
||||
targetPathUq: uniqueIndex("company_secret_bindings_target_path_uq").on(
|
||||
table.companyId,
|
||||
table.targetType,
|
||||
table.targetId,
|
||||
table.configPath,
|
||||
),
|
||||
}),
|
||||
);
|
||||
@@ -10,6 +10,10 @@ export const companySecretVersions = pgTable(
|
||||
version: integer("version").notNull(),
|
||||
material: jsonb("material").$type<Record<string, unknown>>().notNull(),
|
||||
valueSha256: text("value_sha256").notNull(),
|
||||
providerVersionRef: text("provider_version_ref"),
|
||||
status: text("status").notNull().default("current"),
|
||||
fingerprintSha256: text("fingerprint_sha256").notNull(),
|
||||
rotationJobId: text("rotation_job_id"),
|
||||
createdByAgentId: uuid("created_by_agent_id").references(() => agents.id, { onDelete: "set null" }),
|
||||
createdByUserId: text("created_by_user_id"),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
@@ -18,6 +22,7 @@ export const companySecretVersions = pgTable(
|
||||
(table) => ({
|
||||
secretIdx: index("company_secret_versions_secret_idx").on(table.secretId, table.createdAt),
|
||||
valueHashIdx: index("company_secret_versions_value_sha256_idx").on(table.valueSha256),
|
||||
fingerprintIdx: index("company_secret_versions_fingerprint_idx").on(table.fingerprintSha256),
|
||||
secretVersionUq: uniqueIndex("company_secret_versions_secret_version_uq").on(table.secretId, table.version),
|
||||
}),
|
||||
);
|
||||
@@ -1,4 +1,4 @@
|
||||
import { pgTable, uuid, text, timestamp, integer, index, uniqueIndex } from "drizzle-orm/pg-core";
|
||||
import { pgTable, uuid, text, timestamp, integer, jsonb, index, uniqueIndex } from "drizzle-orm/pg-core";
|
||||
import { companies } from "./companies.js";
|
||||
import { agents } from "./agents.js";
|
||||
|
||||
@@ -7,11 +7,19 @@ export const companySecrets = pgTable(
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
companyId: uuid("company_id").notNull().references(() => companies.id),
|
||||
key: text("key").notNull(),
|
||||
name: text("name").notNull(),
|
||||
provider: text("provider").notNull().default("local_encrypted"),
|
||||
status: text("status").notNull().default("active"),
|
||||
managedMode: text("managed_mode").notNull().default("paperclip_managed"),
|
||||
externalRef: text("external_ref"),
|
||||
providerConfigId: text("provider_config_id"),
|
||||
providerMetadata: jsonb("provider_metadata").$type<Record<string, unknown>>(),
|
||||
latestVersion: integer("latest_version").notNull().default(1),
|
||||
description: text("description"),
|
||||
lastResolvedAt: timestamp("last_resolved_at", { withTimezone: true }),
|
||||
lastRotatedAt: timestamp("last_rotated_at", { withTimezone: true }),
|
||||
deletedAt: timestamp("deleted_at", { withTimezone: true }),
|
||||
createdByAgentId: uuid("created_by_agent_id").references(() => agents.id, { onDelete: "set null" }),
|
||||
createdByUserId: text("created_by_user_id"),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
@@ -21,5 +29,6 @@ export const companySecrets = pgTable(
|
||||
companyIdx: index("company_secrets_company_idx").on(table.companyId),
|
||||
companyProviderIdx: index("company_secrets_company_provider_idx").on(table.companyId, table.provider),
|
||||
companyNameUq: uniqueIndex("company_secrets_company_name_uq").on(table.companyId, table.name),
|
||||
companyKeyUq: uniqueIndex("company_secrets_company_key_uq").on(table.companyId, table.key),
|
||||
}),
|
||||
);
|
||||
@@ -61,6 +61,8 @@ export { approvalComments } from "./approval_comments.js";
|
||||
export { activityLog } from "./activity_log.js";
|
||||
export { companySecrets } from "./company_secrets.js";
|
||||
export { companySecretVersions } from "./company_secret_versions.js";
|
||||
export { companySecretBindings } from "./company_secret_bindings.js";
|
||||
export { secretAccessEvents } from "./secret_access_events.js";
|
||||
export { companySkills } from "./company_skills.js";
|
||||
export { plugins } from "./plugins.js";
|
||||
export { pluginConfig } from "./plugin_config.js";
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { index, integer, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core";
|
||||
import { companies } from "./companies.js";
|
||||
import { companySecrets } from "./company_secrets.js";
|
||||
import { heartbeatRuns } from "./heartbeat_runs.js";
|
||||
import { issues } from "./issues.js";
|
||||
import { plugins } from "./plugins.js";
|
||||
|
||||
export const secretAccessEvents = pgTable(
|
||||
"secret_access_events",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
companyId: uuid("company_id").notNull().references(() => companies.id),
|
||||
secretId: uuid("secret_id").notNull().references(() => companySecrets.id, { onDelete: "cascade" }),
|
||||
version: integer("version"),
|
||||
provider: text("provider").notNull(),
|
||||
actorType: text("actor_type").notNull(),
|
||||
actorId: text("actor_id"),
|
||||
consumerType: text("consumer_type").notNull(),
|
||||
consumerId: text("consumer_id").notNull(),
|
||||
configPath: text("config_path"),
|
||||
issueId: uuid("issue_id").references(() => issues.id, { onDelete: "set null" }),
|
||||
heartbeatRunId: uuid("heartbeat_run_id").references(() => heartbeatRuns.id, { onDelete: "set null" }),
|
||||
pluginId: uuid("plugin_id").references(() => plugins.id, { onDelete: "set null" }),
|
||||
outcome: text("outcome").notNull(),
|
||||
errorCode: text("error_code"),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => ({
|
||||
companyCreatedIdx: index("secret_access_events_company_created_idx").on(table.companyId, table.createdAt),
|
||||
secretCreatedIdx: index("secret_access_events_secret_created_idx").on(table.secretId, table.createdAt),
|
||||
consumerIdx: index("secret_access_events_consumer_idx").on(table.companyId, table.consumerType, table.consumerId),
|
||||
runIdx: index("secret_access_events_run_idx").on(table.heartbeatRunId),
|
||||
}),
|
||||
);
|
||||
@@ -395,6 +395,36 @@ export const SECRET_PROVIDERS = [
|
||||
] as const;
|
||||
export type SecretProvider = (typeof SECRET_PROVIDERS)[number];
|
||||
|
||||
export const SECRET_STATUSES = ["active", "disabled", "archived", "deleted"] as const;
|
||||
export type SecretStatus = (typeof SECRET_STATUSES)[number];
|
||||
|
||||
export const SECRET_MANAGED_MODES = ["paperclip_managed", "external_reference"] as const;
|
||||
export type SecretManagedMode = (typeof SECRET_MANAGED_MODES)[number];
|
||||
|
||||
export const SECRET_VERSION_STATUSES = [
|
||||
"current",
|
||||
"previous",
|
||||
"disabled",
|
||||
"destroyed",
|
||||
"failed",
|
||||
] as const;
|
||||
export type SecretVersionStatus = (typeof SECRET_VERSION_STATUSES)[number];
|
||||
|
||||
export const SECRET_BINDING_TARGET_TYPES = [
|
||||
"agent",
|
||||
"project",
|
||||
"environment",
|
||||
"routine",
|
||||
"plugin",
|
||||
"issue",
|
||||
"run",
|
||||
"system",
|
||||
] as const;
|
||||
export type SecretBindingTargetType = (typeof SECRET_BINDING_TARGET_TYPES)[number];
|
||||
|
||||
export const SECRET_ACCESS_OUTCOMES = ["success", "failure"] as const;
|
||||
export type SecretAccessOutcome = (typeof SECRET_ACCESS_OUTCOMES)[number];
|
||||
|
||||
export const STORAGE_PROVIDERS = ["local_disk", "s3"] as const;
|
||||
export type StorageProvider = (typeof STORAGE_PROVIDERS)[number];
|
||||
|
||||
|
||||
@@ -530,7 +530,16 @@ export type {
|
||||
EnvBinding,
|
||||
AgentEnvConfig,
|
||||
CompanySecret,
|
||||
CompanySecretBinding,
|
||||
CompanySecretVersion,
|
||||
SecretAccessEvent,
|
||||
SecretAccessOutcome,
|
||||
SecretBindingTargetType,
|
||||
SecretManagedMode,
|
||||
SecretProviderDescriptor,
|
||||
SecretStatus,
|
||||
SecretVersionSelector,
|
||||
SecretVersionStatus,
|
||||
Routine,
|
||||
RoutineManagedByPlugin,
|
||||
RoutineVariable,
|
||||
@@ -826,7 +835,9 @@ export {
|
||||
envBindingSchema,
|
||||
envConfigSchema,
|
||||
createSecretSchema,
|
||||
createSecretBindingSchema,
|
||||
rotateSecretSchema,
|
||||
secretBindingTargetSchema,
|
||||
updateSecretSchema,
|
||||
createRoutineSchema,
|
||||
updateRoutineSchema,
|
||||
|
||||
@@ -244,7 +244,15 @@ export type {
|
||||
EnvBinding,
|
||||
AgentEnvConfig,
|
||||
CompanySecret,
|
||||
CompanySecretBinding,
|
||||
CompanySecretVersion,
|
||||
SecretAccessEvent,
|
||||
SecretAccessOutcome,
|
||||
SecretBindingTargetType,
|
||||
SecretManagedMode,
|
||||
SecretProviderDescriptor,
|
||||
SecretStatus,
|
||||
SecretVersionStatus,
|
||||
} from "./secrets.js";
|
||||
export type {
|
||||
Routine,
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
export type SecretProvider =
|
||||
| "local_encrypted"
|
||||
| "aws_secrets_manager"
|
||||
| "gcp_secret_manager"
|
||||
| "vault";
|
||||
import type {
|
||||
SecretAccessOutcome,
|
||||
SecretBindingTargetType,
|
||||
SecretManagedMode,
|
||||
SecretProvider,
|
||||
SecretStatus,
|
||||
SecretVersionStatus,
|
||||
} from "../constants.js";
|
||||
|
||||
export type { SecretAccessOutcome, SecretBindingTargetType, SecretManagedMode, SecretProvider, SecretStatus, SecretVersionStatus };
|
||||
|
||||
export type SecretVersionSelector = number | "latest";
|
||||
|
||||
@@ -25,11 +30,19 @@ export type AgentEnvConfig = Record<string, EnvBinding>;
|
||||
export interface CompanySecret {
|
||||
id: string;
|
||||
companyId: string;
|
||||
key: string;
|
||||
name: string;
|
||||
provider: SecretProvider;
|
||||
status: SecretStatus;
|
||||
managedMode: SecretManagedMode;
|
||||
externalRef: string | null;
|
||||
providerConfigId: string | null;
|
||||
providerMetadata: Record<string, unknown> | null;
|
||||
latestVersion: number;
|
||||
description: string | null;
|
||||
lastResolvedAt: Date | null;
|
||||
lastRotatedAt: Date | null;
|
||||
deletedAt: Date | null;
|
||||
createdByAgentId: string | null;
|
||||
createdByUserId: string | null;
|
||||
createdAt: Date;
|
||||
@@ -41,3 +54,48 @@ export interface SecretProviderDescriptor {
|
||||
label: string;
|
||||
requiresExternalRef: boolean;
|
||||
}
|
||||
|
||||
export interface CompanySecretVersion {
|
||||
id: string;
|
||||
secretId: string;
|
||||
version: number;
|
||||
providerVersionRef: string | null;
|
||||
status: SecretVersionStatus;
|
||||
fingerprintSha256: string;
|
||||
rotationJobId: string | null;
|
||||
createdAt: Date;
|
||||
revokedAt: Date | null;
|
||||
}
|
||||
|
||||
export interface CompanySecretBinding {
|
||||
id: string;
|
||||
companyId: string;
|
||||
secretId: string;
|
||||
targetType: SecretBindingTargetType;
|
||||
targetId: string;
|
||||
configPath: string;
|
||||
versionSelector: SecretVersionSelector;
|
||||
required: boolean;
|
||||
label: string | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface SecretAccessEvent {
|
||||
id: string;
|
||||
companyId: string;
|
||||
secretId: string;
|
||||
version: number | null;
|
||||
provider: SecretProvider;
|
||||
actorType: "agent" | "user" | "system" | "plugin";
|
||||
actorId: string | null;
|
||||
consumerType: SecretBindingTargetType;
|
||||
consumerId: string;
|
||||
configPath: string | null;
|
||||
issueId: string | null;
|
||||
heartbeatRunId: string | null;
|
||||
pluginId: string | null;
|
||||
outcome: SecretAccessOutcome;
|
||||
errorCode: string | null;
|
||||
createdAt: Date;
|
||||
}
|
||||
@@ -282,8 +282,11 @@ export {
|
||||
envBindingSchema,
|
||||
envConfigSchema,
|
||||
createSecretSchema,
|
||||
createSecretBindingSchema,
|
||||
rotateSecretSchema,
|
||||
secretBindingTargetSchema,
|
||||
updateSecretSchema,
|
||||
type CreateSecretBinding,
|
||||
type CreateSecret,
|
||||
type RotateSecret,
|
||||
type UpdateSecret,
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { z } from "zod";
|
||||
import { SECRET_PROVIDERS } from "../constants.js";
|
||||
import {
|
||||
SECRET_BINDING_TARGET_TYPES,
|
||||
SECRET_MANAGED_MODES,
|
||||
SECRET_PROVIDERS,
|
||||
SECRET_STATUSES,
|
||||
} from "../constants.js";
|
||||
|
||||
export const envBindingPlainSchema = z.object({
|
||||
type: z.literal("plain"),
|
||||
@@ -23,10 +28,13 @@ export const envConfigSchema = z.record(envBindingSchema);
|
||||
|
||||
export const createSecretSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
key: z.string().min(1).regex(/^[a-zA-Z0-9_.-]+$/).optional(),
|
||||
provider: z.enum(SECRET_PROVIDERS).optional(),
|
||||
managedMode: z.enum(SECRET_MANAGED_MODES).optional(),
|
||||
value: z.string().min(1),
|
||||
description: z.string().optional().nullable(),
|
||||
externalRef: z.string().optional().nullable(),
|
||||
providerMetadata: z.record(z.unknown()).optional().nullable(),
|
||||
});
|
||||
|
||||
export type CreateSecret = z.infer<typeof createSecretSchema>;
|
||||
@@ -34,14 +42,33 @@ export type CreateSecret = z.infer<typeof createSecretSchema>;
|
||||
export const rotateSecretSchema = z.object({
|
||||
value: z.string().min(1),
|
||||
externalRef: z.string().optional().nullable(),
|
||||
providerVersionRef: z.string().optional().nullable(),
|
||||
});
|
||||
|
||||
export type RotateSecret = z.infer<typeof rotateSecretSchema>;
|
||||
|
||||
export const updateSecretSchema = z.object({
|
||||
name: z.string().min(1).optional(),
|
||||
key: z.string().min(1).regex(/^[a-zA-Z0-9_.-]+$/).optional(),
|
||||
status: z.enum(SECRET_STATUSES).optional(),
|
||||
description: z.string().optional().nullable(),
|
||||
externalRef: z.string().optional().nullable(),
|
||||
providerMetadata: z.record(z.unknown()).optional().nullable(),
|
||||
});
|
||||
|
||||
export type UpdateSecret = z.infer<typeof updateSecretSchema>;
|
||||
|
||||
export const secretBindingTargetSchema = z.object({
|
||||
targetType: z.enum(SECRET_BINDING_TARGET_TYPES),
|
||||
targetId: z.string().min(1),
|
||||
configPath: z.string().min(1),
|
||||
});
|
||||
|
||||
export const createSecretBindingSchema = secretBindingTargetSchema.extend({
|
||||
secretId: z.string().uuid(),
|
||||
versionSelector: z.union([z.literal("latest"), z.number().int().positive()]).default("latest"),
|
||||
required: z.boolean().default(true),
|
||||
label: z.string().optional().nullable(),
|
||||
});
|
||||
|
||||
export type CreateSecretBinding = z.infer<typeof createSecretBindingSchema>;
|
||||
@@ -0,0 +1,144 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||
import {
|
||||
companies,
|
||||
companySecretBindings,
|
||||
companySecretVersions,
|
||||
companySecrets,
|
||||
createDb,
|
||||
secretAccessEvents,
|
||||
} from "@paperclipai/db";
|
||||
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
|
||||
import { secretService } from "../services/secrets.js";
|
||||
|
||||
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
||||
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
||||
|
||||
if (!embeddedPostgresSupport.supported) {
|
||||
console.warn(
|
||||
`Skipping secrets service tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
|
||||
);
|
||||
}
|
||||
|
||||
describeEmbeddedPostgres("secretService", () => {
|
||||
let stopDb: (() => Promise<void>) | null = null;
|
||||
let db!: ReturnType<typeof createDb>;
|
||||
|
||||
beforeAll(async () => {
|
||||
const started = await startEmbeddedPostgresTestDatabase("secrets-service");
|
||||
stopDb = started.stop;
|
||||
db = createDb(started.connectionString);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await db.delete(secretAccessEvents);
|
||||
await db.delete(companySecretBindings);
|
||||
await db.delete(companySecretVersions);
|
||||
await db.delete(companySecrets);
|
||||
await db.delete(companies);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await stopDb?.();
|
||||
});
|
||||
|
||||
async function seedCompany(name = "Acme") {
|
||||
const companyId = randomUUID();
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name,
|
||||
issuePrefix: `T${companyId.slice(0, 7)}`.toUpperCase(),
|
||||
status: "active",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
return companyId;
|
||||
}
|
||||
|
||||
it("rejects cross-company secret references during env normalization", async () => {
|
||||
const companyA = await seedCompany("A");
|
||||
const companyB = await seedCompany("B");
|
||||
const svc = secretService(db);
|
||||
const foreignSecret = await svc.create(companyB, {
|
||||
name: `foreign-${randomUUID()}`,
|
||||
provider: "local_encrypted",
|
||||
value: "secret-value",
|
||||
});
|
||||
|
||||
await expect(
|
||||
svc.normalizeEnvBindingsForPersistence(companyA, {
|
||||
API_KEY: { type: "secret_ref", secretId: foreignSecret.id, version: "latest" },
|
||||
}),
|
||||
).rejects.toThrow(/same company/i);
|
||||
});
|
||||
|
||||
it("prevents duplicate bindings for a target config path", async () => {
|
||||
const companyId = await seedCompany();
|
||||
const svc = secretService(db);
|
||||
const firstSecret = await svc.create(companyId, {
|
||||
name: `first-${randomUUID()}`,
|
||||
provider: "local_encrypted",
|
||||
value: "one",
|
||||
});
|
||||
const secondSecret = await svc.create(companyId, {
|
||||
name: `second-${randomUUID()}`,
|
||||
provider: "local_encrypted",
|
||||
value: "two",
|
||||
});
|
||||
|
||||
await svc.createBinding({
|
||||
companyId,
|
||||
secretId: firstSecret.id,
|
||||
targetType: "agent",
|
||||
targetId: "agent-1",
|
||||
configPath: "env.API_KEY",
|
||||
});
|
||||
|
||||
await expect(
|
||||
svc.createBinding({
|
||||
companyId,
|
||||
secretId: secondSecret.id,
|
||||
targetType: "agent",
|
||||
targetId: "agent-1",
|
||||
configPath: "env.API_KEY",
|
||||
}),
|
||||
).rejects.toThrow(/already exists/i);
|
||||
});
|
||||
|
||||
it("enforces binding context and records value-free access events", async () => {
|
||||
const companyId = await seedCompany();
|
||||
const svc = secretService(db);
|
||||
const secret = await svc.create(companyId, {
|
||||
name: `runtime-${randomUUID()}`,
|
||||
provider: "local_encrypted",
|
||||
value: "runtime-secret",
|
||||
});
|
||||
const env = {
|
||||
API_KEY: { type: "secret_ref" as const, secretId: secret.id, version: "latest" as const },
|
||||
};
|
||||
|
||||
await svc.syncEnvBindingsForTarget(companyId, { targetType: "agent", targetId: "agent-1" }, env);
|
||||
|
||||
await expect(
|
||||
svc.resolveEnvBindings(companyId, env, {
|
||||
consumerType: "agent",
|
||||
consumerId: "agent-2",
|
||||
actorType: "agent",
|
||||
actorId: "agent-2",
|
||||
}),
|
||||
).rejects.toThrow(/not bound/i);
|
||||
|
||||
const resolved = await svc.resolveEnvBindings(companyId, env, {
|
||||
consumerType: "agent",
|
||||
consumerId: "agent-1",
|
||||
actorType: "agent",
|
||||
actorId: "agent-1",
|
||||
});
|
||||
|
||||
expect(resolved.env.API_KEY).toBe("runtime-secret");
|
||||
const events = await svc.listAccessEvents(companyId, secret.id);
|
||||
expect(events).toHaveLength(2);
|
||||
expect(events.map((event) => event.outcome).sort()).toEqual(["failure", "success"]);
|
||||
expect(JSON.stringify(events)).not.toContain("runtime-secret");
|
||||
});
|
||||
});
|
||||
@@ -2189,6 +2189,14 @@ export function agentRoutes(
|
||||
lastHeartbeatAt: null,
|
||||
});
|
||||
const agent = await materializeDefaultInstructionsBundleForNewAgent(createdAgent, instructionsBundle);
|
||||
const agentEnv = asRecord(agent.adapterConfig)?.env;
|
||||
if (agentEnv) {
|
||||
await secretsSvc.syncEnvBindingsForTarget?.(
|
||||
companyId,
|
||||
{ targetType: "agent", targetId: agent.id },
|
||||
agentEnv,
|
||||
);
|
||||
}
|
||||
|
||||
const actor = getActorInfo(req);
|
||||
await logActivity(db, {
|
||||
@@ -2665,6 +2673,14 @@ export function agentRoutes(
|
||||
res.status(404).json({ error: "Agent not found" });
|
||||
return;
|
||||
}
|
||||
if (touchesAdapterConfiguration) {
|
||||
const agentEnv = asRecord(agent.adapterConfig)?.env;
|
||||
await secretsSvc.syncEnvBindingsForTarget?.(
|
||||
agent.companyId,
|
||||
{ targetType: "agent", targetId: agent.id },
|
||||
agentEnv,
|
||||
);
|
||||
}
|
||||
|
||||
await logActivity(db, {
|
||||
companyId: agent.companyId,
|
||||
|
||||
@@ -142,6 +142,13 @@ export function projectRoutes(db: Db) {
|
||||
);
|
||||
}
|
||||
const project = await svc.create(companyId, projectData);
|
||||
if (project.env) {
|
||||
await secretsSvc.syncEnvBindingsForTarget?.(
|
||||
companyId,
|
||||
{ targetType: "project", targetId: project.id },
|
||||
project.env,
|
||||
);
|
||||
}
|
||||
let createdWorkspaceId: string | null = null;
|
||||
if (workspace) {
|
||||
const createdWorkspace = await svc.createWorkspace(project.id, workspace);
|
||||
@@ -207,6 +214,13 @@ export function projectRoutes(db: Db) {
|
||||
res.status(404).json({ error: "Project not found" });
|
||||
return;
|
||||
}
|
||||
if (body.env !== undefined) {
|
||||
await secretsSvc.syncEnvBindingsForTarget?.(
|
||||
project.companyId,
|
||||
{ targetType: "project", targetId: project.id },
|
||||
project.env,
|
||||
);
|
||||
}
|
||||
|
||||
const actor = getActorInfo(req);
|
||||
await logActivity(db, {
|
||||
|
||||
@@ -45,10 +45,13 @@ export function secretRoutes(db: Db) {
|
||||
companyId,
|
||||
{
|
||||
name: req.body.name,
|
||||
key: req.body.key,
|
||||
provider: req.body.provider ?? defaultProvider,
|
||||
managedMode: req.body.managedMode,
|
||||
value: req.body.value,
|
||||
description: req.body.description,
|
||||
externalRef: req.body.externalRef,
|
||||
providerMetadata: req.body.providerMetadata,
|
||||
},
|
||||
{ userId: req.actor.userId ?? "board", agentId: null },
|
||||
);
|
||||
@@ -110,8 +113,11 @@ export function secretRoutes(db: Db) {
|
||||
|
||||
const updated = await svc.update(id, {
|
||||
name: req.body.name,
|
||||
key: req.body.key,
|
||||
status: req.body.status,
|
||||
description: req.body.description,
|
||||
externalRef: req.body.externalRef,
|
||||
providerMetadata: req.body.providerMetadata,
|
||||
});
|
||||
|
||||
if (!updated) {
|
||||
@@ -132,6 +138,32 @@ export function secretRoutes(db: Db) {
|
||||
res.json(updated);
|
||||
});
|
||||
|
||||
router.get("/secrets/:id/usage", async (req, res) => {
|
||||
assertBoard(req);
|
||||
const id = req.params.id as string;
|
||||
const existing = await svc.getById(id);
|
||||
if (!existing) {
|
||||
res.status(404).json({ error: "Secret not found" });
|
||||
return;
|
||||
}
|
||||
assertCompanyAccess(req, existing.companyId);
|
||||
const bindings = await svc.listBindings(existing.companyId, existing.id);
|
||||
res.json({ secretId: existing.id, bindings });
|
||||
});
|
||||
|
||||
router.get("/secrets/:id/access-events", async (req, res) => {
|
||||
assertBoard(req);
|
||||
const id = req.params.id as string;
|
||||
const existing = await svc.getById(id);
|
||||
if (!existing) {
|
||||
res.status(404).json({ error: "Secret not found" });
|
||||
return;
|
||||
}
|
||||
assertCompanyAccess(req, existing.companyId);
|
||||
const events = await svc.listAccessEvents(existing.companyId, existing.id);
|
||||
res.json(events);
|
||||
});
|
||||
|
||||
router.delete("/secrets/:id", async (req, res) => {
|
||||
assertBoard(req);
|
||||
const id = req.params.id as string;
|
||||
|
||||
@@ -327,6 +327,10 @@ type RuntimeConfigSecretResolver = Pick<
|
||||
|
||||
export async function resolveExecutionRunAdapterConfig(input: {
|
||||
companyId: string;
|
||||
agentId?: string | null;
|
||||
issueId?: string | null;
|
||||
heartbeatRunId?: string | null;
|
||||
projectId?: string | null;
|
||||
executionRunConfig: Record<string, unknown>;
|
||||
projectEnv: unknown;
|
||||
secretsSvc: RuntimeConfigSecretResolver;
|
||||
@@ -334,9 +338,32 @@ export async function resolveExecutionRunAdapterConfig(input: {
|
||||
const { config: resolvedConfig, secretKeys } = await input.secretsSvc.resolveAdapterConfigForRuntime(
|
||||
input.companyId,
|
||||
input.executionRunConfig,
|
||||
input.agentId
|
||||
? {
|
||||
consumerType: "agent",
|
||||
consumerId: input.agentId,
|
||||
actorType: "agent",
|
||||
actorId: input.agentId,
|
||||
issueId: input.issueId ?? null,
|
||||
heartbeatRunId: input.heartbeatRunId ?? null,
|
||||
}
|
||||
: undefined,
|
||||
);
|
||||
const projectEnvResolution = input.projectEnv
|
||||
? await input.secretsSvc.resolveEnvBindings(input.companyId, input.projectEnv)
|
||||
? await input.secretsSvc.resolveEnvBindings(
|
||||
input.companyId,
|
||||
input.projectEnv,
|
||||
input.projectId
|
||||
? {
|
||||
consumerType: "project",
|
||||
consumerId: input.projectId,
|
||||
actorType: "agent",
|
||||
actorId: input.agentId ?? null,
|
||||
issueId: input.issueId ?? null,
|
||||
heartbeatRunId: input.heartbeatRunId ?? null,
|
||||
}
|
||||
: undefined,
|
||||
)
|
||||
: { env: {}, secretKeys: new Set<string>() };
|
||||
if (Object.keys(projectEnvResolution.env).length > 0) {
|
||||
resolvedConfig.env = {
|
||||
@@ -6790,6 +6817,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
const projectContext = executionProjectId
|
||||
? await db
|
||||
.select({
|
||||
id: projects.id,
|
||||
executionWorkspacePolicy: projects.executionWorkspacePolicy,
|
||||
env: projects.env,
|
||||
})
|
||||
@@ -6997,6 +7025,10 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
const executionRunConfig = stripWorkspaceRuntimeFromExecutionRunConfig(mergedConfig);
|
||||
const { resolvedConfig, secretKeys } = await resolveExecutionRunAdapterConfig({
|
||||
companyId: agent.companyId,
|
||||
agentId: agent.id,
|
||||
issueId,
|
||||
heartbeatRunId: run.id,
|
||||
projectId: projectContext?.id ?? null,
|
||||
executionRunConfig,
|
||||
projectEnv: projectContext?.env ?? null,
|
||||
secretsSvc,
|
||||
|
||||
+332
-14
@@ -1,7 +1,18 @@
|
||||
import { and, desc, eq } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { companySecrets, companySecretVersions } from "@paperclipai/db";
|
||||
import type { AgentEnvConfig, EnvBinding, SecretProvider } from "@paperclipai/shared";
|
||||
import {
|
||||
companySecretBindings,
|
||||
companySecrets,
|
||||
companySecretVersions,
|
||||
secretAccessEvents,
|
||||
} from "@paperclipai/db";
|
||||
import type {
|
||||
AgentEnvConfig,
|
||||
EnvBinding,
|
||||
SecretBindingTargetType,
|
||||
SecretProvider,
|
||||
SecretVersionSelector,
|
||||
} from "@paperclipai/shared";
|
||||
import { envBindingSchema } from "@paperclipai/shared";
|
||||
import { conflict, notFound, unprocessable } from "../errors.js";
|
||||
import { getSecretProvider, listSecretProviders } from "../secrets/provider-registry.js";
|
||||
@@ -15,6 +26,17 @@ type CanonicalEnvBinding =
|
||||
| { type: "plain"; value: string }
|
||||
| { type: "secret_ref"; secretId: string; version: number | "latest" };
|
||||
|
||||
type SecretConsumerContext = {
|
||||
consumerType: SecretBindingTargetType;
|
||||
consumerId: string;
|
||||
configPath?: string | null;
|
||||
actorType?: "agent" | "user" | "system" | "plugin";
|
||||
actorId?: string | null;
|
||||
issueId?: string | null;
|
||||
heartbeatRunId?: string | null;
|
||||
pluginId?: string | null;
|
||||
};
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
|
||||
return value as Record<string, unknown>;
|
||||
@@ -24,6 +46,15 @@ function isSensitiveEnvKey(key: string) {
|
||||
return SENSITIVE_ENV_KEY_RE.test(key);
|
||||
}
|
||||
|
||||
function normalizeSecretKey(input: string) {
|
||||
return input
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9_.-]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 120);
|
||||
}
|
||||
|
||||
function canonicalizeBinding(binding: EnvBinding): CanonicalEnvBinding {
|
||||
if (typeof binding === "string") {
|
||||
return { type: "plain", value: binding };
|
||||
@@ -73,6 +104,79 @@ export function secretService(db: Db) {
|
||||
.then((rows) => rows[0] ?? null);
|
||||
}
|
||||
|
||||
async function getBinding(input: {
|
||||
companyId: string;
|
||||
secretId: string;
|
||||
consumerType: SecretBindingTargetType;
|
||||
consumerId: string;
|
||||
configPath: string;
|
||||
}) {
|
||||
return db
|
||||
.select()
|
||||
.from(companySecretBindings)
|
||||
.where(
|
||||
and(
|
||||
eq(companySecretBindings.companyId, input.companyId),
|
||||
eq(companySecretBindings.secretId, input.secretId),
|
||||
eq(companySecretBindings.targetType, input.consumerType),
|
||||
eq(companySecretBindings.targetId, input.consumerId),
|
||||
eq(companySecretBindings.configPath, input.configPath),
|
||||
),
|
||||
)
|
||||
.then((rows) => rows[0] ?? null);
|
||||
}
|
||||
|
||||
async function assertBindingContext(
|
||||
companyId: string,
|
||||
secretId: string,
|
||||
context: SecretConsumerContext | undefined,
|
||||
) {
|
||||
if (!context) return;
|
||||
if (!context.configPath) {
|
||||
throw unprocessable("Secret resolution requires a binding config path");
|
||||
}
|
||||
const binding = await getBinding({
|
||||
companyId,
|
||||
secretId,
|
||||
consumerType: context.consumerType,
|
||||
consumerId: context.consumerId,
|
||||
configPath: context.configPath,
|
||||
});
|
||||
if (!binding) {
|
||||
throw unprocessable(
|
||||
`Secret is not bound to ${context.consumerType}:${context.consumerId} at ${context.configPath}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function recordAccessEvent(input: {
|
||||
companyId: string;
|
||||
secretId: string;
|
||||
version: number | null;
|
||||
provider: SecretProvider;
|
||||
context: SecretConsumerContext | undefined;
|
||||
outcome: "success" | "failure";
|
||||
errorCode?: string | null;
|
||||
}) {
|
||||
if (!input.context) return;
|
||||
await db.insert(secretAccessEvents).values({
|
||||
companyId: input.companyId,
|
||||
secretId: input.secretId,
|
||||
version: input.version,
|
||||
provider: input.provider,
|
||||
actorType: input.context.actorType ?? "system",
|
||||
actorId: input.context.actorId ?? null,
|
||||
consumerType: input.context.consumerType,
|
||||
consumerId: input.context.consumerId,
|
||||
configPath: input.context.configPath ?? null,
|
||||
issueId: input.context.issueId ?? null,
|
||||
heartbeatRunId: input.context.heartbeatRunId ?? null,
|
||||
pluginId: input.context.pluginId ?? null,
|
||||
outcome: input.outcome,
|
||||
errorCode: input.errorCode ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
async function assertSecretInCompany(companyId: string, secretId: string) {
|
||||
const secret = await getById(secretId);
|
||||
if (!secret) throw notFound("Secret not found");
|
||||
@@ -84,16 +188,53 @@ export function secretService(db: Db) {
|
||||
companyId: string,
|
||||
secretId: string,
|
||||
version: number | "latest",
|
||||
context?: SecretConsumerContext,
|
||||
): Promise<string> {
|
||||
const secret = await assertSecretInCompany(companyId, secretId);
|
||||
const resolvedVersion = version === "latest" ? secret.latestVersion : version;
|
||||
const versionRow = await getSecretVersion(secret.id, resolvedVersion);
|
||||
if (!versionRow) throw notFound("Secret version not found");
|
||||
const provider = getSecretProvider(secret.provider as SecretProvider);
|
||||
return provider.resolveVersion({
|
||||
material: versionRow.material as Record<string, unknown>,
|
||||
externalRef: secret.externalRef,
|
||||
});
|
||||
const providerId = secret.provider as SecretProvider;
|
||||
try {
|
||||
if (secret.status !== "active") {
|
||||
throw unprocessable("Secret is not active");
|
||||
}
|
||||
await assertBindingContext(companyId, secret.id, context);
|
||||
const versionRow = await getSecretVersion(secret.id, resolvedVersion);
|
||||
if (!versionRow) throw notFound("Secret version not found");
|
||||
if (versionRow.status === "disabled" || versionRow.status === "destroyed" || versionRow.revokedAt) {
|
||||
throw unprocessable("Secret version is not active");
|
||||
}
|
||||
const provider = getSecretProvider(providerId);
|
||||
const value = await provider.resolveVersion({
|
||||
material: versionRow.material as Record<string, unknown>,
|
||||
externalRef: secret.externalRef,
|
||||
});
|
||||
await Promise.all([
|
||||
db
|
||||
.update(companySecrets)
|
||||
.set({ lastResolvedAt: new Date(), updatedAt: new Date() })
|
||||
.where(eq(companySecrets.id, secret.id)),
|
||||
recordAccessEvent({
|
||||
companyId,
|
||||
secretId: secret.id,
|
||||
version: resolvedVersion,
|
||||
provider: providerId,
|
||||
context,
|
||||
outcome: "success",
|
||||
}),
|
||||
]);
|
||||
return value;
|
||||
} catch (err) {
|
||||
await recordAccessEvent({
|
||||
companyId,
|
||||
secretId: secret.id,
|
||||
version: resolvedVersion,
|
||||
provider: providerId,
|
||||
context,
|
||||
outcome: "failure",
|
||||
errorCode: err instanceof Error ? err.message.slice(0, 120) : "resolution_failed",
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async function normalizeEnvConfig(
|
||||
@@ -162,6 +303,24 @@ export function secretService(db: Db) {
|
||||
.where(eq(companySecrets.companyId, companyId))
|
||||
.orderBy(desc(companySecrets.createdAt)),
|
||||
|
||||
listBindings: (companyId: string, secretId?: string) =>
|
||||
db
|
||||
.select()
|
||||
.from(companySecretBindings)
|
||||
.where(
|
||||
secretId
|
||||
? and(eq(companySecretBindings.companyId, companyId), eq(companySecretBindings.secretId, secretId))
|
||||
: eq(companySecretBindings.companyId, companyId),
|
||||
)
|
||||
.orderBy(desc(companySecretBindings.createdAt)),
|
||||
|
||||
listAccessEvents: (companyId: string, secretId: string) =>
|
||||
db
|
||||
.select()
|
||||
.from(secretAccessEvents)
|
||||
.where(and(eq(secretAccessEvents.companyId, companyId), eq(secretAccessEvents.secretId, secretId)))
|
||||
.orderBy(desc(secretAccessEvents.createdAt)),
|
||||
|
||||
getById,
|
||||
getByName,
|
||||
resolveSecretValue,
|
||||
@@ -172,13 +331,24 @@ export function secretService(db: Db) {
|
||||
name: string;
|
||||
provider: SecretProvider;
|
||||
value: string;
|
||||
key?: string | null;
|
||||
managedMode?: "paperclip_managed" | "external_reference";
|
||||
description?: string | null;
|
||||
externalRef?: string | null;
|
||||
providerMetadata?: Record<string, unknown> | null;
|
||||
},
|
||||
actor?: { userId?: string | null; agentId?: string | null },
|
||||
) => {
|
||||
const existing = await getByName(companyId, input.name);
|
||||
if (existing) throw conflict(`Secret already exists: ${input.name}`);
|
||||
const key = normalizeSecretKey(input.key ?? input.name);
|
||||
if (!key) throw unprocessable("Secret key is required");
|
||||
const duplicateKey = await db
|
||||
.select()
|
||||
.from(companySecrets)
|
||||
.where(and(eq(companySecrets.companyId, companyId), eq(companySecrets.key, key)))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (duplicateKey) throw conflict(`Secret key already exists: ${key}`);
|
||||
|
||||
const provider = getSecretProvider(input.provider);
|
||||
const prepared = await provider.createVersion({
|
||||
@@ -191,11 +361,16 @@ export function secretService(db: Db) {
|
||||
.insert(companySecrets)
|
||||
.values({
|
||||
companyId,
|
||||
key,
|
||||
name: input.name,
|
||||
provider: input.provider,
|
||||
status: "active",
|
||||
managedMode: input.managedMode ?? "paperclip_managed",
|
||||
externalRef: prepared.externalRef,
|
||||
providerMetadata: input.providerMetadata ?? null,
|
||||
latestVersion: 1,
|
||||
description: input.description ?? null,
|
||||
lastRotatedAt: new Date(),
|
||||
createdByAgentId: actor?.agentId ?? null,
|
||||
createdByUserId: actor?.userId ?? null,
|
||||
})
|
||||
@@ -207,6 +382,8 @@ export function secretService(db: Db) {
|
||||
version: 1,
|
||||
material: prepared.material,
|
||||
valueSha256: prepared.valueSha256,
|
||||
fingerprintSha256: prepared.valueSha256,
|
||||
status: "current",
|
||||
createdByAgentId: actor?.agentId ?? null,
|
||||
createdByUserId: actor?.userId ?? null,
|
||||
});
|
||||
@@ -230,11 +407,17 @@ export function secretService(db: Db) {
|
||||
});
|
||||
|
||||
return db.transaction(async (tx) => {
|
||||
await tx
|
||||
.update(companySecretVersions)
|
||||
.set({ status: "previous" })
|
||||
.where(eq(companySecretVersions.secretId, secret.id));
|
||||
await tx.insert(companySecretVersions).values({
|
||||
secretId: secret.id,
|
||||
version: nextVersion,
|
||||
material: prepared.material,
|
||||
valueSha256: prepared.valueSha256,
|
||||
fingerprintSha256: prepared.valueSha256,
|
||||
status: "current",
|
||||
createdByAgentId: actor?.agentId ?? null,
|
||||
createdByUserId: actor?.userId ?? null,
|
||||
});
|
||||
@@ -244,6 +427,7 @@ export function secretService(db: Db) {
|
||||
.set({
|
||||
latestVersion: nextVersion,
|
||||
externalRef: prepared.externalRef,
|
||||
lastRotatedAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(companySecrets.id, secret.id))
|
||||
@@ -257,7 +441,14 @@ export function secretService(db: Db) {
|
||||
|
||||
update: async (
|
||||
secretId: string,
|
||||
patch: { name?: string; description?: string | null; externalRef?: string | null },
|
||||
patch: {
|
||||
name?: string;
|
||||
key?: string;
|
||||
status?: "active" | "disabled" | "archived" | "deleted";
|
||||
description?: string | null;
|
||||
externalRef?: string | null;
|
||||
providerMetadata?: Record<string, unknown> | null;
|
||||
},
|
||||
) => {
|
||||
const secret = await getById(secretId);
|
||||
if (!secret) throw notFound("Secret not found");
|
||||
@@ -268,15 +459,33 @@ export function secretService(db: Db) {
|
||||
throw conflict(`Secret already exists: ${patch.name}`);
|
||||
}
|
||||
}
|
||||
const nextKey = patch.key ? normalizeSecretKey(patch.key) : secret.key;
|
||||
if (!nextKey) throw unprocessable("Secret key is required");
|
||||
if (nextKey !== secret.key) {
|
||||
const duplicateKey = await db
|
||||
.select()
|
||||
.from(companySecrets)
|
||||
.where(and(eq(companySecrets.companyId, secret.companyId), eq(companySecrets.key, nextKey)))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (duplicateKey && duplicateKey.id !== secret.id) {
|
||||
throw conflict(`Secret key already exists: ${nextKey}`);
|
||||
}
|
||||
}
|
||||
const deleting = patch.status === "deleted";
|
||||
|
||||
return db
|
||||
.update(companySecrets)
|
||||
.set({
|
||||
key: nextKey,
|
||||
name: patch.name ?? secret.name,
|
||||
status: patch.status ?? secret.status,
|
||||
description:
|
||||
patch.description === undefined ? secret.description : patch.description,
|
||||
externalRef:
|
||||
patch.externalRef === undefined ? secret.externalRef : patch.externalRef,
|
||||
providerMetadata:
|
||||
patch.providerMetadata === undefined ? secret.providerMetadata : patch.providerMetadata,
|
||||
deletedAt: deleting ? new Date() : secret.deletedAt,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(companySecrets.id, secret.id))
|
||||
@@ -284,6 +493,97 @@ export function secretService(db: Db) {
|
||||
.then((rows) => rows[0] ?? null);
|
||||
},
|
||||
|
||||
createBinding: async (input: {
|
||||
companyId: string;
|
||||
secretId: string;
|
||||
targetType: SecretBindingTargetType;
|
||||
targetId: string;
|
||||
configPath: string;
|
||||
versionSelector?: SecretVersionSelector;
|
||||
required?: boolean;
|
||||
label?: string | null;
|
||||
}) => {
|
||||
await assertSecretInCompany(input.companyId, input.secretId);
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(companySecretBindings)
|
||||
.where(
|
||||
and(
|
||||
eq(companySecretBindings.companyId, input.companyId),
|
||||
eq(companySecretBindings.targetType, input.targetType),
|
||||
eq(companySecretBindings.targetId, input.targetId),
|
||||
eq(companySecretBindings.configPath, input.configPath),
|
||||
),
|
||||
)
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (existing) throw conflict(`Secret binding already exists at ${input.configPath}`);
|
||||
return db
|
||||
.insert(companySecretBindings)
|
||||
.values({
|
||||
companyId: input.companyId,
|
||||
secretId: input.secretId,
|
||||
targetType: input.targetType,
|
||||
targetId: input.targetId,
|
||||
configPath: input.configPath,
|
||||
versionSelector: String(input.versionSelector ?? "latest"),
|
||||
required: input.required ?? true,
|
||||
label: input.label ?? null,
|
||||
})
|
||||
.returning()
|
||||
.then((rows) => rows[0]);
|
||||
},
|
||||
|
||||
syncEnvBindingsForTarget: async (
|
||||
companyId: string,
|
||||
target: { targetType: SecretBindingTargetType; targetId: string; pathPrefix?: string },
|
||||
envValue: unknown,
|
||||
) => {
|
||||
const record = asRecord(envValue) ?? {};
|
||||
const refs: Array<{
|
||||
secretId: string;
|
||||
configPath: string;
|
||||
versionSelector: SecretVersionSelector;
|
||||
}> = [];
|
||||
const pathPrefix = target.pathPrefix ?? "env";
|
||||
for (const [key, rawBinding] of Object.entries(record)) {
|
||||
const parsed = envBindingSchema.safeParse(rawBinding);
|
||||
if (!parsed.success) continue;
|
||||
const binding = canonicalizeBinding(parsed.data as EnvBinding);
|
||||
if (binding.type !== "secret_ref") continue;
|
||||
await assertSecretInCompany(companyId, binding.secretId);
|
||||
refs.push({
|
||||
secretId: binding.secretId,
|
||||
configPath: `${pathPrefix}.${key}`,
|
||||
versionSelector: binding.version,
|
||||
});
|
||||
}
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
await tx
|
||||
.delete(companySecretBindings)
|
||||
.where(
|
||||
and(
|
||||
eq(companySecretBindings.companyId, companyId),
|
||||
eq(companySecretBindings.targetType, target.targetType),
|
||||
eq(companySecretBindings.targetId, target.targetId),
|
||||
),
|
||||
);
|
||||
if (refs.length === 0) return;
|
||||
await tx.insert(companySecretBindings).values(
|
||||
refs.map((ref) => ({
|
||||
companyId,
|
||||
secretId: ref.secretId,
|
||||
targetType: target.targetType,
|
||||
targetId: target.targetId,
|
||||
configPath: ref.configPath,
|
||||
versionSelector: String(ref.versionSelector),
|
||||
required: true,
|
||||
})),
|
||||
);
|
||||
});
|
||||
return refs;
|
||||
},
|
||||
|
||||
remove: async (secretId: string) => {
|
||||
const secret = await getById(secretId);
|
||||
if (!secret) return null;
|
||||
@@ -320,7 +620,11 @@ export function secretService(db: Db) {
|
||||
return normalized;
|
||||
},
|
||||
|
||||
resolveEnvBindings: async (companyId: string, envValue: unknown): Promise<{ env: Record<string, string>; secretKeys: Set<string> }> => {
|
||||
resolveEnvBindings: async (
|
||||
companyId: string,
|
||||
envValue: unknown,
|
||||
context?: Omit<SecretConsumerContext, "configPath">,
|
||||
): Promise<{ env: Record<string, string>; secretKeys: Set<string> }> => {
|
||||
const record = asRecord(envValue);
|
||||
if (!record) return { env: {} as Record<string, string>, secretKeys: new Set<string>() };
|
||||
const resolved: Record<string, string> = {};
|
||||
@@ -338,14 +642,23 @@ export function secretService(db: Db) {
|
||||
if (binding.type === "plain") {
|
||||
resolved[key] = binding.value;
|
||||
} else {
|
||||
resolved[key] = await resolveSecretValue(companyId, binding.secretId, binding.version);
|
||||
resolved[key] = await resolveSecretValue(
|
||||
companyId,
|
||||
binding.secretId,
|
||||
binding.version,
|
||||
context ? { ...context, configPath: `env.${key}` } : undefined,
|
||||
);
|
||||
secretKeys.add(key);
|
||||
}
|
||||
}
|
||||
return { env: resolved, secretKeys };
|
||||
},
|
||||
|
||||
resolveAdapterConfigForRuntime: async (companyId: string, adapterConfig: Record<string, unknown>): Promise<{ config: Record<string, unknown>; secretKeys: Set<string> }> => {
|
||||
resolveAdapterConfigForRuntime: async (
|
||||
companyId: string,
|
||||
adapterConfig: Record<string, unknown>,
|
||||
context?: Omit<SecretConsumerContext, "configPath">,
|
||||
): Promise<{ config: Record<string, unknown>; secretKeys: Set<string> }> => {
|
||||
const resolved = { ...adapterConfig };
|
||||
const secretKeys = new Set<string>();
|
||||
if (!Object.prototype.hasOwnProperty.call(adapterConfig, "env")) {
|
||||
@@ -369,7 +682,12 @@ export function secretService(db: Db) {
|
||||
if (binding.type === "plain") {
|
||||
env[key] = binding.value;
|
||||
} else {
|
||||
env[key] = await resolveSecretValue(companyId, binding.secretId, binding.version);
|
||||
env[key] = await resolveSecretValue(
|
||||
companyId,
|
||||
binding.secretId,
|
||||
binding.version,
|
||||
context ? { ...context, configPath: `env.${key}` } : undefined,
|
||||
);
|
||||
secretKeys.add(key);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user