mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 00:53:38 +02:00
Implement provider vault data model and API
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
b84482de60
commit
ef1f56ef4c
16 files changed
+1026
-15
No files matched your search
@@ -0,0 +1,51 @@
|
||||
CREATE TABLE IF NOT EXISTS "company_secret_provider_configs" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"company_id" uuid NOT NULL,
|
||||
"provider" text NOT NULL,
|
||||
"display_name" text NOT NULL,
|
||||
"status" text DEFAULT 'ready' NOT NULL,
|
||||
"is_default" boolean DEFAULT false NOT NULL,
|
||||
"config" jsonb DEFAULT '{}'::jsonb NOT NULL,
|
||||
"health_status" text,
|
||||
"health_checked_at" timestamp with time zone,
|
||||
"health_message" text,
|
||||
"health_details" jsonb,
|
||||
"disabled_at" timestamp with time zone,
|
||||
"created_by_agent_id" uuid,
|
||||
"created_by_user_id" text,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'company_secret_provider_configs_company_id_companies_id_fk') THEN
|
||||
ALTER TABLE "company_secret_provider_configs" ADD CONSTRAINT "company_secret_provider_configs_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("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 = 'company_secret_provider_configs_created_by_agent_id_agents_id_fk') THEN
|
||||
ALTER TABLE "company_secret_provider_configs" ADD CONSTRAINT "company_secret_provider_configs_created_by_agent_id_agents_id_fk" FOREIGN KEY ("created_by_agent_id") REFERENCES "public"."agents"("id") ON DELETE set null ON UPDATE no action;
|
||||
END IF;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
UPDATE "company_secrets"
|
||||
SET "provider_config_id" = NULL
|
||||
WHERE "provider_config_id" IS NOT NULL
|
||||
AND "provider_config_id" !~* '^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$';
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "company_secrets" ALTER COLUMN "provider_config_id" TYPE uuid USING "provider_config_id"::uuid;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'company_secrets_provider_config_id_company_secret_provider_configs_id_fk') THEN
|
||||
ALTER TABLE "company_secrets" ADD CONSTRAINT "company_secrets_provider_config_id_company_secret_provider_configs_id_fk" FOREIGN KEY ("provider_config_id") REFERENCES "public"."company_secret_provider_configs"("id") ON DELETE set null ON UPDATE no action;
|
||||
END IF;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "company_secret_provider_configs_company_idx" ON "company_secret_provider_configs" USING btree ("company_id");
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "company_secret_provider_configs_company_provider_idx" ON "company_secret_provider_configs" USING btree ("company_id","provider");
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "company_secret_provider_configs_default_uq" ON "company_secret_provider_configs" USING btree ("company_id","provider") WHERE "is_default" = true;
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "company_secrets_provider_config_idx" ON "company_secrets" USING btree ("provider_config_id");
|
||||
@@ -582,6 +582,13 @@
|
||||
"when": 1777239150329,
|
||||
"tag": "0082_dry_vision",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 82,
|
||||
"version": "7",
|
||||
"when": 1778074536410,
|
||||
"tag": "0082_company_secret_provider_configs",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { sql } from "drizzle-orm";
|
||||
import { pgTable, uuid, text, timestamp, jsonb, index, uniqueIndex, boolean } from "drizzle-orm/pg-core";
|
||||
import { companies } from "./companies.js";
|
||||
import { agents } from "./agents.js";
|
||||
|
||||
export const companySecretProviderConfigs = pgTable(
|
||||
"company_secret_provider_configs",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }),
|
||||
provider: text("provider").notNull(),
|
||||
displayName: text("display_name").notNull(),
|
||||
status: text("status").notNull().default("ready"),
|
||||
isDefault: boolean("is_default").notNull().default(false),
|
||||
config: jsonb("config").$type<Record<string, unknown>>().notNull().default({}),
|
||||
healthStatus: text("health_status"),
|
||||
healthCheckedAt: timestamp("health_checked_at", { withTimezone: true }),
|
||||
healthMessage: text("health_message"),
|
||||
healthDetails: jsonb("health_details").$type<Record<string, unknown>>(),
|
||||
disabledAt: timestamp("disabled_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(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => ({
|
||||
companyIdx: index("company_secret_provider_configs_company_idx").on(table.companyId),
|
||||
companyProviderIdx: index("company_secret_provider_configs_company_provider_idx").on(table.companyId, table.provider),
|
||||
companyDefaultProviderUq: uniqueIndex("company_secret_provider_configs_default_uq")
|
||||
.on(table.companyId, table.provider)
|
||||
.where(sql`${table.isDefault} = true`),
|
||||
}),
|
||||
);
|
||||
@@ -1,6 +1,7 @@
|
||||
import { pgTable, uuid, text, timestamp, integer, jsonb, index, uniqueIndex } from "drizzle-orm/pg-core";
|
||||
import { companies } from "./companies.js";
|
||||
import { agents } from "./agents.js";
|
||||
import { companySecretProviderConfigs } from "./company_secret_provider_configs.js";
|
||||
|
||||
export const companySecrets = pgTable(
|
||||
"company_secrets",
|
||||
@@ -13,7 +14,7 @@ export const companySecrets = pgTable(
|
||||
status: text("status").notNull().default("active"),
|
||||
managedMode: text("managed_mode").notNull().default("paperclip_managed"),
|
||||
externalRef: text("external_ref"),
|
||||
providerConfigId: text("provider_config_id"),
|
||||
providerConfigId: uuid("provider_config_id").references(() => companySecretProviderConfigs.id, { onDelete: "set null" }),
|
||||
providerMetadata: jsonb("provider_metadata").$type<Record<string, unknown>>(),
|
||||
latestVersion: integer("latest_version").notNull().default(1),
|
||||
description: text("description"),
|
||||
@@ -28,6 +29,7 @@ export const companySecrets = pgTable(
|
||||
(table) => ({
|
||||
companyIdx: index("company_secrets_company_idx").on(table.companyId),
|
||||
companyProviderIdx: index("company_secrets_company_provider_idx").on(table.companyId, table.provider),
|
||||
providerConfigIdx: index("company_secrets_provider_config_idx").on(table.providerConfigId),
|
||||
companyNameUq: uniqueIndex("company_secrets_company_name_uq").on(table.companyId, table.name),
|
||||
companyKeyUq: uniqueIndex("company_secrets_company_key_uq").on(table.companyId, table.key),
|
||||
}),
|
||||
|
||||
@@ -59,6 +59,7 @@ export { financeEvents } from "./finance_events.js";
|
||||
export { approvals } from "./approvals.js";
|
||||
export { approvalComments } from "./approval_comments.js";
|
||||
export { activityLog } from "./activity_log.js";
|
||||
export { companySecretProviderConfigs } from "./company_secret_provider_configs.js";
|
||||
export { companySecrets } from "./company_secrets.js";
|
||||
export { companySecretVersions } from "./company_secret_versions.js";
|
||||
export { companySecretBindings } from "./company_secret_bindings.js";
|
||||
|
||||
@@ -11,6 +11,7 @@ export const API = {
|
||||
goals: `${API_PREFIX}/goals`,
|
||||
approvals: `${API_PREFIX}/approvals`,
|
||||
secrets: `${API_PREFIX}/secrets`,
|
||||
secretProviderConfigs: `${API_PREFIX}/secret-provider-configs`,
|
||||
costs: `${API_PREFIX}/costs`,
|
||||
activity: `${API_PREFIX}/activity`,
|
||||
dashboard: `${API_PREFIX}/dashboard`,
|
||||
|
||||
@@ -395,6 +395,24 @@ export const SECRET_PROVIDERS = [
|
||||
] as const;
|
||||
export type SecretProvider = (typeof SECRET_PROVIDERS)[number];
|
||||
|
||||
export const SECRET_PROVIDER_CONFIG_STATUSES = [
|
||||
"ready",
|
||||
"warning",
|
||||
"coming_soon",
|
||||
"disabled",
|
||||
] as const;
|
||||
export type SecretProviderConfigStatus = (typeof SECRET_PROVIDER_CONFIG_STATUSES)[number];
|
||||
|
||||
export const SECRET_PROVIDER_CONFIG_HEALTH_STATUSES = [
|
||||
"ready",
|
||||
"warning",
|
||||
"error",
|
||||
"coming_soon",
|
||||
"disabled",
|
||||
] as const;
|
||||
export type SecretProviderConfigHealthStatus =
|
||||
(typeof SECRET_PROVIDER_CONFIG_HEALTH_STATUSES)[number];
|
||||
|
||||
export const SECRET_STATUSES = ["active", "disabled", "archived", "deleted"] as const;
|
||||
export type SecretStatus = (typeof SECRET_STATUSES)[number];
|
||||
|
||||
|
||||
@@ -71,6 +71,8 @@ export {
|
||||
APPROVAL_TYPES,
|
||||
APPROVAL_STATUSES,
|
||||
SECRET_PROVIDERS,
|
||||
SECRET_PROVIDER_CONFIG_STATUSES,
|
||||
SECRET_PROVIDER_CONFIG_HEALTH_STATUSES,
|
||||
STORAGE_PROVIDERS,
|
||||
BILLING_TYPES,
|
||||
FINANCE_EVENT_KINDS,
|
||||
@@ -182,6 +184,8 @@ export {
|
||||
type ApprovalType,
|
||||
type ApprovalStatus,
|
||||
type SecretProvider,
|
||||
type SecretProviderConfigStatus,
|
||||
type SecretProviderConfigHealthStatus,
|
||||
type StorageProvider,
|
||||
type BillingType,
|
||||
type FinanceEventKind,
|
||||
@@ -530,6 +534,10 @@ export type {
|
||||
EnvBinding,
|
||||
AgentEnvConfig,
|
||||
CompanySecret,
|
||||
CompanySecretProviderConfig,
|
||||
SecretProviderConfigPayload,
|
||||
SecretProviderConfigHealthDetails,
|
||||
SecretProviderConfigHealthResponse,
|
||||
CompanySecretBinding,
|
||||
CompanySecretVersion,
|
||||
SecretAccessEvent,
|
||||
@@ -835,6 +843,13 @@ export {
|
||||
envBindingSchema,
|
||||
envConfigSchema,
|
||||
createSecretSchema,
|
||||
createSecretProviderConfigSchema,
|
||||
updateSecretProviderConfigSchema,
|
||||
localEncryptedProviderConfigSchema,
|
||||
awsSecretsManagerProviderConfigSchema,
|
||||
gcpSecretManagerProviderConfigSchema,
|
||||
vaultProviderConfigSchema,
|
||||
secretProviderConfigPayloadSchema,
|
||||
createSecretBindingSchema,
|
||||
rotateSecretSchema,
|
||||
secretBindingTargetSchema,
|
||||
@@ -851,6 +866,8 @@ export {
|
||||
routineRevisionSnapshotV1Schema,
|
||||
routineRevisionSnapshotSchema,
|
||||
type CreateSecret,
|
||||
type CreateSecretProviderConfig,
|
||||
type UpdateSecretProviderConfig,
|
||||
type RotateSecret,
|
||||
type UpdateSecret,
|
||||
type CreateRoutine,
|
||||
|
||||
@@ -244,6 +244,10 @@ export type {
|
||||
EnvBinding,
|
||||
AgentEnvConfig,
|
||||
CompanySecret,
|
||||
CompanySecretProviderConfig,
|
||||
SecretProviderConfigPayload,
|
||||
SecretProviderConfigHealthDetails,
|
||||
SecretProviderConfigHealthResponse,
|
||||
CompanySecretBinding,
|
||||
CompanySecretVersion,
|
||||
SecretAccessEvent,
|
||||
|
||||
@@ -3,11 +3,22 @@ import type {
|
||||
SecretBindingTargetType,
|
||||
SecretManagedMode,
|
||||
SecretProvider,
|
||||
SecretProviderConfigHealthStatus,
|
||||
SecretProviderConfigStatus,
|
||||
SecretStatus,
|
||||
SecretVersionStatus,
|
||||
} from "../constants.js";
|
||||
|
||||
export type { SecretAccessOutcome, SecretBindingTargetType, SecretManagedMode, SecretProvider, SecretStatus, SecretVersionStatus };
|
||||
export type {
|
||||
SecretAccessOutcome,
|
||||
SecretBindingTargetType,
|
||||
SecretManagedMode,
|
||||
SecretProvider,
|
||||
SecretProviderConfigHealthStatus,
|
||||
SecretProviderConfigStatus,
|
||||
SecretStatus,
|
||||
SecretVersionStatus,
|
||||
};
|
||||
|
||||
export type SecretVersionSelector = number | "latest";
|
||||
|
||||
@@ -58,6 +69,74 @@ export interface SecretProviderDescriptor {
|
||||
configured?: boolean;
|
||||
}
|
||||
|
||||
export interface LocalEncryptedProviderConfig {
|
||||
backupReminderAcknowledged?: boolean;
|
||||
}
|
||||
|
||||
export interface AwsSecretsManagerProviderConfig {
|
||||
region: string;
|
||||
namespace?: string | null;
|
||||
secretNamePrefix?: string | null;
|
||||
kmsKeyId?: string | null;
|
||||
ownerTag?: string | null;
|
||||
environmentTag?: string | null;
|
||||
}
|
||||
|
||||
export interface GcpSecretManagerProviderConfig {
|
||||
projectId?: string | null;
|
||||
location?: string | null;
|
||||
namespace?: string | null;
|
||||
secretNamePrefix?: string | null;
|
||||
}
|
||||
|
||||
export interface VaultProviderConfig {
|
||||
address?: string | null;
|
||||
namespace?: string | null;
|
||||
mountPath?: string | null;
|
||||
secretPathPrefix?: string | null;
|
||||
}
|
||||
|
||||
export type SecretProviderConfigPayload =
|
||||
| LocalEncryptedProviderConfig
|
||||
| AwsSecretsManagerProviderConfig
|
||||
| GcpSecretManagerProviderConfig
|
||||
| VaultProviderConfig;
|
||||
|
||||
export interface SecretProviderConfigHealthDetails {
|
||||
code: string;
|
||||
message: string;
|
||||
missingFields?: string[];
|
||||
guidance?: string[];
|
||||
}
|
||||
|
||||
export interface CompanySecretProviderConfig {
|
||||
id: string;
|
||||
companyId: string;
|
||||
provider: SecretProvider;
|
||||
displayName: string;
|
||||
status: SecretProviderConfigStatus;
|
||||
isDefault: boolean;
|
||||
config: SecretProviderConfigPayload;
|
||||
healthStatus: SecretProviderConfigHealthStatus | null;
|
||||
healthCheckedAt: Date | null;
|
||||
healthMessage: string | null;
|
||||
healthDetails: SecretProviderConfigHealthDetails | null;
|
||||
disabledAt: Date | null;
|
||||
createdByAgentId: string | null;
|
||||
createdByUserId: string | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface SecretProviderConfigHealthResponse {
|
||||
configId: string;
|
||||
provider: SecretProvider;
|
||||
status: SecretProviderConfigHealthStatus;
|
||||
message: string;
|
||||
details: SecretProviderConfigHealthDetails;
|
||||
checkedAt: Date;
|
||||
}
|
||||
|
||||
export interface CompanySecretVersion {
|
||||
id: string;
|
||||
secretId: string;
|
||||
|
||||
@@ -282,12 +282,21 @@ export {
|
||||
envBindingSchema,
|
||||
envConfigSchema,
|
||||
createSecretSchema,
|
||||
createSecretProviderConfigSchema,
|
||||
updateSecretProviderConfigSchema,
|
||||
localEncryptedProviderConfigSchema,
|
||||
awsSecretsManagerProviderConfigSchema,
|
||||
gcpSecretManagerProviderConfigSchema,
|
||||
vaultProviderConfigSchema,
|
||||
secretProviderConfigPayloadSchema,
|
||||
createSecretBindingSchema,
|
||||
rotateSecretSchema,
|
||||
secretBindingTargetSchema,
|
||||
updateSecretSchema,
|
||||
type CreateSecretBinding,
|
||||
type CreateSecret,
|
||||
type CreateSecretProviderConfig,
|
||||
type UpdateSecretProviderConfig,
|
||||
type RotateSecret,
|
||||
type UpdateSecret,
|
||||
} from "./secret.js";
|
||||
|
||||
@@ -2,6 +2,7 @@ import { z } from "zod";
|
||||
import {
|
||||
SECRET_BINDING_TARGET_TYPES,
|
||||
SECRET_MANAGED_MODES,
|
||||
SECRET_PROVIDER_CONFIG_STATUSES,
|
||||
SECRET_PROVIDERS,
|
||||
SECRET_STATUSES,
|
||||
} from "../constants.js";
|
||||
@@ -30,6 +31,7 @@ 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(),
|
||||
providerConfigId: z.string().uuid().optional().nullable(),
|
||||
managedMode: z.enum(SECRET_MANAGED_MODES).optional(),
|
||||
value: z.string().min(1).optional().nullable(),
|
||||
description: z.string().optional().nullable(),
|
||||
@@ -77,6 +79,7 @@ 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(),
|
||||
providerConfigId: z.string().uuid().optional().nullable(),
|
||||
description: z.string().optional().nullable(),
|
||||
externalRef: z.string().optional().nullable(),
|
||||
providerMetadata: z.record(z.unknown()).optional().nullable(),
|
||||
@@ -98,3 +101,114 @@ export const createSecretBindingSchema = secretBindingTargetSchema.extend({
|
||||
});
|
||||
|
||||
export type CreateSecretBinding = z.infer<typeof createSecretBindingSchema>;
|
||||
|
||||
const safeShortText = z.string().trim().min(1).max(160);
|
||||
const optionalSafeShortText = safeShortText.optional().nullable();
|
||||
|
||||
const deniedProviderConfigKeyPattern =
|
||||
/^(access[-_]?key([-_]?id)?|secret[-_]?access[-_]?key|secret[-_]?key|token|password|passwd|credential|credentials|private[-_]?key|pem|jwt|session[-_]?token|service[-_]?account([-_]?json)?|client[-_]?secret|secret[-_]?id|unseal[-_]?key|recovery[-_]?key|key[-_]?file([-_]?path)?|token[-_]?file([-_]?path)?)$/i;
|
||||
|
||||
function rejectSensitiveProviderConfigKeys(value: unknown, ctx: z.RefinementCtx) {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return;
|
||||
for (const key of Object.keys(value)) {
|
||||
if (!deniedProviderConfigKeyPattern.test(key)) continue;
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["config", key],
|
||||
message: `Provider vault config cannot persist sensitive field: ${key}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const localEncryptedProviderConfigSchema = z.object({
|
||||
backupReminderAcknowledged: z.boolean().optional(),
|
||||
}).strict();
|
||||
|
||||
export const awsSecretsManagerProviderConfigSchema = z.object({
|
||||
region: z.string().trim().regex(/^[a-z]{2}(?:-gov)?-[a-z]+-\d+$/, "Invalid AWS region"),
|
||||
namespace: optionalSafeShortText,
|
||||
secretNamePrefix: optionalSafeShortText,
|
||||
kmsKeyId: z.string().trim().min(1).max(512).optional().nullable(),
|
||||
ownerTag: optionalSafeShortText,
|
||||
environmentTag: optionalSafeShortText,
|
||||
}).strict();
|
||||
|
||||
export const gcpSecretManagerProviderConfigSchema = z.object({
|
||||
projectId: z.string().trim().min(1).max(128).regex(/^[a-z][a-z0-9-]{4,127}$/).optional().nullable(),
|
||||
location: optionalSafeShortText,
|
||||
namespace: optionalSafeShortText,
|
||||
secretNamePrefix: optionalSafeShortText,
|
||||
}).strict();
|
||||
|
||||
export const vaultProviderConfigSchema = z.object({
|
||||
address: z.string().trim().url().optional().nullable(),
|
||||
namespace: optionalSafeShortText,
|
||||
mountPath: optionalSafeShortText,
|
||||
secretPathPrefix: optionalSafeShortText,
|
||||
}).strict();
|
||||
|
||||
export const secretProviderConfigPayloadSchema = z.discriminatedUnion("provider", [
|
||||
z.object({ provider: z.literal("local_encrypted"), config: localEncryptedProviderConfigSchema }),
|
||||
z.object({ provider: z.literal("aws_secrets_manager"), config: awsSecretsManagerProviderConfigSchema }),
|
||||
z.object({ provider: z.literal("gcp_secret_manager"), config: gcpSecretManagerProviderConfigSchema }),
|
||||
z.object({ provider: z.literal("vault"), config: vaultProviderConfigSchema }),
|
||||
]);
|
||||
|
||||
export const createSecretProviderConfigSchema = z.object({
|
||||
provider: z.enum(SECRET_PROVIDERS),
|
||||
displayName: z.string().trim().min(1).max(120),
|
||||
status: z.enum(SECRET_PROVIDER_CONFIG_STATUSES).optional(),
|
||||
isDefault: z.boolean().optional(),
|
||||
config: z.record(z.unknown()).default({}),
|
||||
}).superRefine((value, ctx) => {
|
||||
rejectSensitiveProviderConfigKeys(value.config, ctx);
|
||||
const parsed = secretProviderConfigPayloadSchema.safeParse({
|
||||
provider: value.provider,
|
||||
config: value.config,
|
||||
});
|
||||
if (!parsed.success) {
|
||||
for (const issue of parsed.error.issues) {
|
||||
ctx.addIssue({
|
||||
...issue,
|
||||
path: issue.path[0] === "config" ? issue.path : ["config", ...issue.path],
|
||||
});
|
||||
}
|
||||
}
|
||||
const status = value.status ?? (["gcp_secret_manager", "vault"].includes(value.provider) ? "coming_soon" : "ready");
|
||||
if ((value.provider === "gcp_secret_manager" || value.provider === "vault") && status !== "coming_soon" && status !== "disabled") {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["status"],
|
||||
message: `${value.provider} provider vaults are locked while coming soon`,
|
||||
});
|
||||
}
|
||||
if ((status === "coming_soon" || status === "disabled") && value.isDefault) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["isDefault"],
|
||||
message: "Only ready or warning provider vaults can be default",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export type CreateSecretProviderConfig = z.infer<typeof createSecretProviderConfigSchema>;
|
||||
|
||||
export const updateSecretProviderConfigSchema = z.object({
|
||||
displayName: z.string().trim().min(1).max(120).optional(),
|
||||
status: z.enum(SECRET_PROVIDER_CONFIG_STATUSES).optional(),
|
||||
isDefault: z.boolean().optional(),
|
||||
config: z.record(z.unknown()).optional(),
|
||||
}).superRefine((value, ctx) => {
|
||||
if (value.config !== undefined) {
|
||||
rejectSensitiveProviderConfigKeys(value.config, ctx);
|
||||
}
|
||||
if ((value.status === "coming_soon" || value.status === "disabled") && value.isDefault) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["isDefault"],
|
||||
message: "Only ready or warning provider vaults can be default",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export type UpdateSecretProviderConfig = z.infer<typeof updateSecretProviderConfigSchema>;
|
||||
@@ -7,6 +7,13 @@ import { errorHandler } from "../middleware/error-handler.js";
|
||||
const mockSecretService = vi.hoisted(() => ({
|
||||
listProviders: vi.fn(),
|
||||
checkProviders: vi.fn(),
|
||||
listProviderConfigs: vi.fn(),
|
||||
getProviderConfigById: vi.fn(),
|
||||
createProviderConfig: vi.fn(),
|
||||
updateProviderConfig: vi.fn(),
|
||||
disableProviderConfig: vi.fn(),
|
||||
setDefaultProviderConfig: vi.fn(),
|
||||
checkProviderConfigHealth: vi.fn(),
|
||||
create: vi.fn(),
|
||||
}));
|
||||
const mockLogActivity = vi.hoisted(() => vi.fn());
|
||||
@@ -16,17 +23,17 @@ vi.mock("../services/index.js", () => ({
|
||||
logActivity: mockLogActivity,
|
||||
}));
|
||||
|
||||
function createApp() {
|
||||
function createApp(actor: Record<string, unknown> = {
|
||||
type: "board",
|
||||
userId: "user-1",
|
||||
source: "session",
|
||||
companyIds: ["company-1"],
|
||||
memberships: [{ companyId: "company-1", status: "active", membershipRole: "admin" }],
|
||||
}) {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
(req as any).actor = {
|
||||
type: "board",
|
||||
userId: "user-1",
|
||||
source: "session",
|
||||
companyIds: ["company-1"],
|
||||
memberships: [{ companyId: "company-1", status: "active", membershipRole: "admin" }],
|
||||
};
|
||||
(req as any).actor = actor;
|
||||
next();
|
||||
});
|
||||
app.use("/api", secretRoutes({} as any));
|
||||
@@ -36,9 +43,9 @@ function createApp() {
|
||||
|
||||
describe("secret routes", () => {
|
||||
beforeEach(() => {
|
||||
mockSecretService.listProviders.mockReset();
|
||||
mockSecretService.checkProviders.mockReset();
|
||||
mockSecretService.create.mockReset();
|
||||
for (const mock of Object.values(mockSecretService)) {
|
||||
mock.mockReset();
|
||||
}
|
||||
mockLogActivity.mockReset();
|
||||
});
|
||||
|
||||
@@ -79,4 +86,110 @@ describe("secret routes", () => {
|
||||
expect(JSON.stringify(res.body)).toMatch(/Managed secrets cannot set externalRef/);
|
||||
expect(mockSecretService.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects provider vault routes for non-board actors", async () => {
|
||||
const res = await request(createApp({
|
||||
type: "agent",
|
||||
agentId: "agent-1",
|
||||
companyId: "company-1",
|
||||
})).get("/api/companies/company-1/secret-provider-configs");
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(mockSecretService.listProviderConfigs).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects provider vault cross-company access before calling the service", async () => {
|
||||
const res = await request(createApp({
|
||||
type: "board",
|
||||
userId: "user-1",
|
||||
source: "session",
|
||||
companyIds: ["company-2"],
|
||||
memberships: [{ companyId: "company-2", status: "active", membershipRole: "admin" }],
|
||||
})).get("/api/companies/company-1/secret-provider-configs");
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(mockSecretService.listProviderConfigs).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects sensitive provider vault config fields", async () => {
|
||||
const res = await request(createApp()).post("/api/companies/company-1/secret-provider-configs").send({
|
||||
provider: "aws_secrets_manager",
|
||||
displayName: "AWS prod",
|
||||
config: {
|
||||
region: "us-east-1",
|
||||
accessKeyId: "AKIA...",
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(JSON.stringify(res.body)).toMatch(/sensitive field/i);
|
||||
expect(mockSecretService.createProviderConfig).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects ready status for coming-soon provider vaults", async () => {
|
||||
const res = await request(createApp()).post("/api/companies/company-1/secret-provider-configs").send({
|
||||
provider: "vault",
|
||||
displayName: "Vault draft",
|
||||
status: "ready",
|
||||
config: {
|
||||
address: "https://vault.example.com",
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(JSON.stringify(res.body)).toMatch(/locked while coming soon/i);
|
||||
expect(mockSecretService.createProviderConfig).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("creates provider vaults and logs safe activity details", async () => {
|
||||
const createdAt = new Date("2026-05-06T00:00:00.000Z");
|
||||
mockSecretService.createProviderConfig.mockResolvedValue({
|
||||
id: "11111111-1111-4111-8111-111111111111",
|
||||
companyId: "company-1",
|
||||
provider: "aws_secrets_manager",
|
||||
displayName: "AWS prod",
|
||||
status: "ready",
|
||||
isDefault: true,
|
||||
config: { region: "us-east-1" },
|
||||
healthStatus: null,
|
||||
healthCheckedAt: null,
|
||||
healthMessage: null,
|
||||
healthDetails: null,
|
||||
disabledAt: null,
|
||||
createdByAgentId: null,
|
||||
createdByUserId: "user-1",
|
||||
createdAt,
|
||||
updatedAt: createdAt,
|
||||
});
|
||||
|
||||
const res = await request(createApp()).post("/api/companies/company-1/secret-provider-configs").send({
|
||||
provider: "aws_secrets_manager",
|
||||
displayName: "AWS prod",
|
||||
isDefault: true,
|
||||
config: { region: "us-east-1" },
|
||||
});
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(mockSecretService.createProviderConfig).toHaveBeenCalledWith(
|
||||
"company-1",
|
||||
{
|
||||
provider: "aws_secrets_manager",
|
||||
displayName: "AWS prod",
|
||||
status: undefined,
|
||||
isDefault: true,
|
||||
config: { region: "us-east-1" },
|
||||
},
|
||||
{ userId: "user-1", agentId: null },
|
||||
);
|
||||
expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({
|
||||
action: "secret_provider_config.created",
|
||||
details: {
|
||||
provider: "aws_secrets_manager",
|
||||
displayName: "AWS prod",
|
||||
status: "ready",
|
||||
isDefault: true,
|
||||
},
|
||||
}));
|
||||
expect(JSON.stringify(mockLogActivity.mock.calls)).not.toContain("accessKey");
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,7 @@ import { eq } from "drizzle-orm";
|
||||
import {
|
||||
companies,
|
||||
companySecretBindings,
|
||||
companySecretProviderConfigs,
|
||||
companySecretVersions,
|
||||
companySecrets,
|
||||
createDb,
|
||||
@@ -35,7 +36,7 @@ describeEmbeddedPostgres("secretService", () => {
|
||||
mkdirSync(secretsTmpDir, { recursive: true });
|
||||
process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE = path.join(secretsTmpDir, "master.key");
|
||||
const started = await startEmbeddedPostgresTestDatabase("secrets-service");
|
||||
stopDb = started.stop;
|
||||
stopDb = started.cleanup;
|
||||
db = createDb(started.connectionString);
|
||||
});
|
||||
|
||||
@@ -45,6 +46,7 @@ describeEmbeddedPostgres("secretService", () => {
|
||||
await db.delete(companySecretBindings);
|
||||
await db.delete(companySecretVersions);
|
||||
await db.delete(companySecrets);
|
||||
await db.delete(companySecretProviderConfigs);
|
||||
await db.delete(companies);
|
||||
});
|
||||
|
||||
@@ -191,6 +193,68 @@ describeEmbeddedPostgres("secretService", () => {
|
||||
).rejects.toThrow(/not bound/i);
|
||||
});
|
||||
|
||||
it("keeps one default provider vault per company provider", async () => {
|
||||
const companyId = await seedCompany();
|
||||
const svc = secretService(db);
|
||||
|
||||
const first = await svc.createProviderConfig(companyId, {
|
||||
provider: "local_encrypted",
|
||||
displayName: "Local primary",
|
||||
isDefault: true,
|
||||
config: {},
|
||||
});
|
||||
const second = await svc.createProviderConfig(companyId, {
|
||||
provider: "local_encrypted",
|
||||
displayName: "Local secondary",
|
||||
isDefault: true,
|
||||
config: {},
|
||||
});
|
||||
|
||||
const rows = await svc.listProviderConfigs(companyId);
|
||||
expect(rows.find((row) => row.id === first.id)?.isDefault).toBe(false);
|
||||
expect(rows.find((row) => row.id === second.id)?.isDefault).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects provider vaults from another company when creating a secret", async () => {
|
||||
const companyA = await seedCompany("A");
|
||||
const companyB = await seedCompany("B");
|
||||
const svc = secretService(db);
|
||||
const foreignVault = await svc.createProviderConfig(companyB, {
|
||||
provider: "local_encrypted",
|
||||
displayName: "Foreign vault",
|
||||
config: {},
|
||||
});
|
||||
|
||||
await expect(
|
||||
svc.create(companyA, {
|
||||
name: `managed-${randomUUID()}`,
|
||||
provider: "local_encrypted",
|
||||
providerConfigId: foreignVault.id,
|
||||
value: "runtime-secret",
|
||||
}),
|
||||
).rejects.toThrow(/same company/i);
|
||||
});
|
||||
|
||||
it("blocks coming-soon provider vaults from secret selection", async () => {
|
||||
const companyId = await seedCompany();
|
||||
const svc = secretService(db);
|
||||
const draftVault = await svc.createProviderConfig(companyId, {
|
||||
provider: "gcp_secret_manager",
|
||||
displayName: "GCP draft",
|
||||
config: { projectId: "paperclip-prod1" },
|
||||
});
|
||||
|
||||
expect(draftVault.status).toBe("coming_soon");
|
||||
await expect(
|
||||
svc.create(companyId, {
|
||||
name: `draft-${randomUUID()}`,
|
||||
provider: "gcp_secret_manager",
|
||||
providerConfigId: draftVault.id,
|
||||
value: "runtime-secret",
|
||||
}),
|
||||
).rejects.toThrow(/coming soon/i);
|
||||
});
|
||||
|
||||
it("rejects externalRef overrides on managed secrets", async () => {
|
||||
const companyId = await seedCompany();
|
||||
const svc = secretService(db);
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Router } from "express";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import {
|
||||
createSecretProviderConfigSchema,
|
||||
createSecretSchema,
|
||||
rotateSecretSchema,
|
||||
updateSecretProviderConfigSchema,
|
||||
updateSecretSchema,
|
||||
} from "@paperclipai/shared";
|
||||
import { validate } from "../middleware/validate.js";
|
||||
@@ -30,6 +32,197 @@ export function secretRoutes(db: Db) {
|
||||
res.json({ providers: checks });
|
||||
});
|
||||
|
||||
router.get("/companies/:companyId/secret-provider-configs", async (req, res) => {
|
||||
assertBoard(req);
|
||||
const companyId = req.params.companyId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
res.json(await svc.listProviderConfigs(companyId));
|
||||
});
|
||||
|
||||
router.post("/companies/:companyId/secret-provider-configs", validate(createSecretProviderConfigSchema), async (req, res) => {
|
||||
assertBoard(req);
|
||||
const companyId = req.params.companyId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
|
||||
const created = await svc.createProviderConfig(
|
||||
companyId,
|
||||
{
|
||||
provider: req.body.provider,
|
||||
displayName: req.body.displayName,
|
||||
status: req.body.status,
|
||||
isDefault: req.body.isDefault,
|
||||
config: req.body.config,
|
||||
},
|
||||
{ userId: req.actor.userId ?? "board", agentId: null },
|
||||
);
|
||||
|
||||
await logActivity(db, {
|
||||
companyId,
|
||||
actorType: "user",
|
||||
actorId: req.actor.userId ?? "board",
|
||||
action: "secret_provider_config.created",
|
||||
entityType: "secret_provider_config",
|
||||
entityId: created.id,
|
||||
details: {
|
||||
provider: created.provider,
|
||||
displayName: created.displayName,
|
||||
status: created.status,
|
||||
isDefault: created.isDefault,
|
||||
},
|
||||
});
|
||||
|
||||
res.status(201).json(created);
|
||||
});
|
||||
|
||||
router.get("/secret-provider-configs/:id", async (req, res) => {
|
||||
assertBoard(req);
|
||||
const existing = await svc.getProviderConfigById(req.params.id as string);
|
||||
if (!existing) {
|
||||
res.status(404).json({ error: "Provider vault not found" });
|
||||
return;
|
||||
}
|
||||
assertCompanyAccess(req, existing.companyId);
|
||||
res.json(existing);
|
||||
});
|
||||
|
||||
router.patch("/secret-provider-configs/:id", validate(updateSecretProviderConfigSchema), async (req, res) => {
|
||||
assertBoard(req);
|
||||
const id = req.params.id as string;
|
||||
const existing = await svc.getProviderConfigById(id);
|
||||
if (!existing) {
|
||||
res.status(404).json({ error: "Provider vault not found" });
|
||||
return;
|
||||
}
|
||||
assertCompanyAccess(req, existing.companyId);
|
||||
|
||||
const updated = await svc.updateProviderConfig(id, {
|
||||
displayName: req.body.displayName,
|
||||
status: req.body.status,
|
||||
isDefault: req.body.isDefault,
|
||||
config: req.body.config,
|
||||
});
|
||||
if (!updated) {
|
||||
res.status(404).json({ error: "Provider vault not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
await logActivity(db, {
|
||||
companyId: updated.companyId,
|
||||
actorType: "user",
|
||||
actorId: req.actor.userId ?? "board",
|
||||
action: "secret_provider_config.updated",
|
||||
entityType: "secret_provider_config",
|
||||
entityId: updated.id,
|
||||
details: {
|
||||
provider: updated.provider,
|
||||
displayName: updated.displayName,
|
||||
status: updated.status,
|
||||
isDefault: updated.isDefault,
|
||||
},
|
||||
});
|
||||
|
||||
res.json(updated);
|
||||
});
|
||||
|
||||
router.delete("/secret-provider-configs/:id", async (req, res) => {
|
||||
assertBoard(req);
|
||||
const id = req.params.id as string;
|
||||
const existing = await svc.getProviderConfigById(id);
|
||||
if (!existing) {
|
||||
res.status(404).json({ error: "Provider vault not found" });
|
||||
return;
|
||||
}
|
||||
assertCompanyAccess(req, existing.companyId);
|
||||
|
||||
const disabled = await svc.disableProviderConfig(id);
|
||||
if (!disabled) {
|
||||
res.status(404).json({ error: "Provider vault not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
await logActivity(db, {
|
||||
companyId: disabled.companyId,
|
||||
actorType: "user",
|
||||
actorId: req.actor.userId ?? "board",
|
||||
action: "secret_provider_config.disabled",
|
||||
entityType: "secret_provider_config",
|
||||
entityId: disabled.id,
|
||||
details: {
|
||||
provider: disabled.provider,
|
||||
displayName: disabled.displayName,
|
||||
status: disabled.status,
|
||||
},
|
||||
});
|
||||
|
||||
res.json(disabled);
|
||||
});
|
||||
|
||||
router.post("/secret-provider-configs/:id/default", async (req, res) => {
|
||||
assertBoard(req);
|
||||
const id = req.params.id as string;
|
||||
const existing = await svc.getProviderConfigById(id);
|
||||
if (!existing) {
|
||||
res.status(404).json({ error: "Provider vault not found" });
|
||||
return;
|
||||
}
|
||||
assertCompanyAccess(req, existing.companyId);
|
||||
|
||||
const updated = await svc.setDefaultProviderConfig(id);
|
||||
if (!updated) {
|
||||
res.status(404).json({ error: "Provider vault not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
await logActivity(db, {
|
||||
companyId: updated.companyId,
|
||||
actorType: "user",
|
||||
actorId: req.actor.userId ?? "board",
|
||||
action: "secret_provider_config.default_set",
|
||||
entityType: "secret_provider_config",
|
||||
entityId: updated.id,
|
||||
details: {
|
||||
provider: updated.provider,
|
||||
displayName: updated.displayName,
|
||||
isDefault: updated.isDefault,
|
||||
},
|
||||
});
|
||||
|
||||
res.json(updated);
|
||||
});
|
||||
|
||||
router.post("/secret-provider-configs/:id/health", async (req, res) => {
|
||||
assertBoard(req);
|
||||
const id = req.params.id as string;
|
||||
const existing = await svc.getProviderConfigById(id);
|
||||
if (!existing) {
|
||||
res.status(404).json({ error: "Provider vault not found" });
|
||||
return;
|
||||
}
|
||||
assertCompanyAccess(req, existing.companyId);
|
||||
|
||||
const health = await svc.checkProviderConfigHealth(id);
|
||||
if (!health) {
|
||||
res.status(404).json({ error: "Provider vault not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
await logActivity(db, {
|
||||
companyId: existing.companyId,
|
||||
actorType: "user",
|
||||
actorId: req.actor.userId ?? "board",
|
||||
action: "secret_provider_config.health_checked",
|
||||
entityType: "secret_provider_config",
|
||||
entityId: existing.id,
|
||||
details: {
|
||||
provider: existing.provider,
|
||||
status: health.status,
|
||||
code: health.details.code,
|
||||
},
|
||||
});
|
||||
|
||||
res.json(health);
|
||||
});
|
||||
|
||||
router.get("/companies/:companyId/secrets", async (req, res) => {
|
||||
assertBoard(req);
|
||||
const companyId = req.params.companyId as string;
|
||||
@@ -49,6 +242,7 @@ export function secretRoutes(db: Db) {
|
||||
name: req.body.name,
|
||||
key: req.body.key,
|
||||
provider: req.body.provider ?? defaultProvider,
|
||||
providerConfigId: req.body.providerConfigId,
|
||||
managedMode: req.body.managedMode,
|
||||
value: req.body.value,
|
||||
description: req.body.description,
|
||||
@@ -119,6 +313,7 @@ export function secretRoutes(db: Db) {
|
||||
name: req.body.name,
|
||||
key: req.body.key,
|
||||
status: req.body.status,
|
||||
providerConfigId: req.body.providerConfigId,
|
||||
description: req.body.description,
|
||||
externalRef: req.body.externalRef,
|
||||
providerMetadata: req.body.providerMetadata,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { and, desc, eq } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import {
|
||||
companySecretBindings,
|
||||
companySecretProviderConfigs,
|
||||
companySecrets,
|
||||
companySecretVersions,
|
||||
secretAccessEvents,
|
||||
@@ -11,9 +12,17 @@ import type {
|
||||
EnvBinding,
|
||||
SecretBindingTargetType,
|
||||
SecretProvider,
|
||||
SecretProviderConfigHealthResponse,
|
||||
SecretProviderConfigHealthStatus,
|
||||
SecretProviderConfigStatus,
|
||||
SecretVersionSelector,
|
||||
} from "@paperclipai/shared";
|
||||
import { envBindingSchema } from "@paperclipai/shared";
|
||||
import {
|
||||
createSecretProviderConfigSchema,
|
||||
envBindingSchema,
|
||||
secretProviderConfigPayloadSchema,
|
||||
updateSecretProviderConfigSchema,
|
||||
} from "@paperclipai/shared";
|
||||
import { conflict, notFound, unprocessable } from "../errors.js";
|
||||
import {
|
||||
checkSecretProviders,
|
||||
@@ -25,6 +34,10 @@ const ENV_KEY_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
||||
const SENSITIVE_ENV_KEY_RE =
|
||||
/(api[-_]?key|access[-_]?token|auth(?:_?token)?|authorization|bearer|secret|passwd|password|credential|jwt|private[-_]?key|cookie|connectionstring)/i;
|
||||
const REDACTED_SENTINEL = "***REDACTED***";
|
||||
const COMING_SOON_SECRET_PROVIDERS: ReadonlySet<SecretProvider> = new Set([
|
||||
"gcp_secret_manager",
|
||||
"vault",
|
||||
]);
|
||||
|
||||
type CanonicalEnvBinding =
|
||||
| { type: "plain"; value: string }
|
||||
@@ -89,6 +102,25 @@ function canonicalizeBinding(binding: EnvBinding): CanonicalEnvBinding {
|
||||
};
|
||||
}
|
||||
|
||||
function defaultProviderConfigStatus(provider: SecretProvider): SecretProviderConfigStatus {
|
||||
return COMING_SOON_SECRET_PROVIDERS.has(provider) ? "coming_soon" : "ready";
|
||||
}
|
||||
|
||||
function assertSelectableProviderConfig(config: {
|
||||
provider: string;
|
||||
status: string;
|
||||
companyId: string;
|
||||
}, companyId: string, provider: SecretProvider) {
|
||||
if (config.companyId !== companyId) throw unprocessable("Provider vault must belong to same company");
|
||||
if (config.provider !== provider) throw unprocessable("Provider vault must match the secret provider");
|
||||
if (config.status === "coming_soon") {
|
||||
throw unprocessable("Provider vault is locked while coming soon");
|
||||
}
|
||||
if (config.status === "disabled") {
|
||||
throw unprocessable("Provider vault is disabled");
|
||||
}
|
||||
}
|
||||
|
||||
export function secretService(db: Db) {
|
||||
type NormalizeEnvOptions = {
|
||||
strictMode?: boolean;
|
||||
@@ -204,6 +236,96 @@ export function secretService(db: Db) {
|
||||
return secret;
|
||||
}
|
||||
|
||||
async function getProviderConfigById(id: string) {
|
||||
return db
|
||||
.select()
|
||||
.from(companySecretProviderConfigs)
|
||||
.where(eq(companySecretProviderConfigs.id, id))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
}
|
||||
|
||||
async function assertProviderConfigForSecret(
|
||||
companyId: string,
|
||||
provider: SecretProvider,
|
||||
providerConfigId: string | null | undefined,
|
||||
) {
|
||||
if (!providerConfigId) return null;
|
||||
const providerConfig = await getProviderConfigById(providerConfigId);
|
||||
if (!providerConfig) throw notFound("Provider vault not found");
|
||||
assertSelectableProviderConfig(providerConfig, companyId, provider);
|
||||
return providerConfig;
|
||||
}
|
||||
|
||||
function validateProviderConfigPayload(
|
||||
provider: SecretProvider,
|
||||
config: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
const parsed = secretProviderConfigPayloadSchema.safeParse({ provider, config });
|
||||
if (!parsed.success) {
|
||||
throw unprocessable("Invalid provider vault config", parsed.error.flatten());
|
||||
}
|
||||
return parsed.data.config;
|
||||
}
|
||||
|
||||
function providerConfigHealth(input: {
|
||||
id: string;
|
||||
provider: SecretProvider;
|
||||
status: SecretProviderConfigStatus;
|
||||
config: Record<string, unknown>;
|
||||
}): Omit<SecretProviderConfigHealthResponse, "checkedAt"> {
|
||||
if (input.status === "disabled") {
|
||||
return {
|
||||
configId: input.id,
|
||||
provider: input.provider,
|
||||
status: "disabled",
|
||||
message: "Provider vault is disabled.",
|
||||
details: { code: "disabled", message: "Provider vault is disabled." },
|
||||
};
|
||||
}
|
||||
if (input.status === "coming_soon" || COMING_SOON_SECRET_PROVIDERS.has(input.provider)) {
|
||||
return {
|
||||
configId: input.id,
|
||||
provider: input.provider,
|
||||
status: "coming_soon",
|
||||
message: "Provider vault runtime is locked while coming soon.",
|
||||
details: {
|
||||
code: "runtime_locked",
|
||||
message: "Provider vault runtime is locked while coming soon.",
|
||||
guidance: ["Draft metadata may be saved, but create, rotate, and resolve stay unavailable."],
|
||||
},
|
||||
};
|
||||
}
|
||||
if (input.provider === "aws_secrets_manager" && typeof input.config.region !== "string") {
|
||||
return {
|
||||
configId: input.id,
|
||||
provider: input.provider,
|
||||
status: "warning",
|
||||
message: "AWS Secrets Manager vault is missing region routing metadata.",
|
||||
details: {
|
||||
code: "missing_config",
|
||||
message: "AWS Secrets Manager vault is missing region routing metadata.",
|
||||
missingFields: ["region"],
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
configId: input.id,
|
||||
provider: input.provider,
|
||||
status: input.status === "warning" ? "warning" : "ready",
|
||||
message:
|
||||
input.status === "warning"
|
||||
? "Provider vault is saved but needs operator attention."
|
||||
: "Provider vault metadata is ready.",
|
||||
details: {
|
||||
code: input.status === "warning" ? "needs_attention" : "metadata_ready",
|
||||
message:
|
||||
input.status === "warning"
|
||||
? "Provider vault is saved but needs operator attention."
|
||||
: "Provider vault metadata is ready.",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveSecretValueInternal(
|
||||
companyId: string,
|
||||
secretId: string,
|
||||
@@ -347,6 +469,174 @@ export function secretService(db: Db) {
|
||||
|
||||
checkProviders: () => checkSecretProviders(),
|
||||
|
||||
listProviderConfigs: (companyId: string) =>
|
||||
db
|
||||
.select()
|
||||
.from(companySecretProviderConfigs)
|
||||
.where(eq(companySecretProviderConfigs.companyId, companyId))
|
||||
.orderBy(desc(companySecretProviderConfigs.createdAt)),
|
||||
|
||||
getProviderConfigById,
|
||||
|
||||
createProviderConfig: async (
|
||||
companyId: string,
|
||||
input: {
|
||||
provider: SecretProvider;
|
||||
displayName: string;
|
||||
status?: SecretProviderConfigStatus;
|
||||
isDefault?: boolean;
|
||||
config?: Record<string, unknown>;
|
||||
},
|
||||
actor?: { userId?: string | null; agentId?: string | null },
|
||||
) => {
|
||||
const parsed = createSecretProviderConfigSchema.safeParse(input);
|
||||
if (!parsed.success) throw unprocessable("Invalid provider vault config", parsed.error.flatten());
|
||||
const status = input.status ?? defaultProviderConfigStatus(input.provider);
|
||||
if ((status === "coming_soon" || status === "disabled") && input.isDefault) {
|
||||
throw unprocessable("Only ready or warning provider vaults can be default");
|
||||
}
|
||||
const normalizedConfig = validateProviderConfigPayload(input.provider, input.config ?? {});
|
||||
return db.transaction(async (tx) => {
|
||||
if (input.isDefault) {
|
||||
await tx
|
||||
.update(companySecretProviderConfigs)
|
||||
.set({ isDefault: false, updatedAt: new Date() })
|
||||
.where(and(
|
||||
eq(companySecretProviderConfigs.companyId, companyId),
|
||||
eq(companySecretProviderConfigs.provider, input.provider),
|
||||
));
|
||||
}
|
||||
return tx
|
||||
.insert(companySecretProviderConfigs)
|
||||
.values({
|
||||
companyId,
|
||||
provider: input.provider,
|
||||
displayName: input.displayName.trim(),
|
||||
status,
|
||||
isDefault: input.isDefault ?? false,
|
||||
config: normalizedConfig,
|
||||
disabledAt: status === "disabled" ? new Date() : null,
|
||||
createdByAgentId: actor?.agentId ?? null,
|
||||
createdByUserId: actor?.userId ?? null,
|
||||
})
|
||||
.returning()
|
||||
.then((rows) => rows[0]);
|
||||
});
|
||||
},
|
||||
|
||||
updateProviderConfig: async (
|
||||
id: string,
|
||||
patch: {
|
||||
displayName?: string;
|
||||
status?: SecretProviderConfigStatus;
|
||||
isDefault?: boolean;
|
||||
config?: Record<string, unknown>;
|
||||
},
|
||||
) => {
|
||||
const existing = await getProviderConfigById(id);
|
||||
if (!existing) return null;
|
||||
const parsed = updateSecretProviderConfigSchema.safeParse(patch);
|
||||
if (!parsed.success) throw unprocessable("Invalid provider vault config", parsed.error.flatten());
|
||||
const provider = existing.provider as SecretProvider;
|
||||
const status = patch.status ?? (existing.status as SecretProviderConfigStatus);
|
||||
if (COMING_SOON_SECRET_PROVIDERS.has(provider) && status !== "coming_soon" && status !== "disabled") {
|
||||
throw unprocessable(`${provider} provider vaults are locked while coming soon`);
|
||||
}
|
||||
if ((status === "coming_soon" || status === "disabled") && patch.isDefault) {
|
||||
throw unprocessable("Only ready or warning provider vaults can be default");
|
||||
}
|
||||
const normalizedConfig =
|
||||
patch.config === undefined
|
||||
? existing.config
|
||||
: validateProviderConfigPayload(provider, patch.config);
|
||||
return db.transaction(async (tx) => {
|
||||
if (patch.isDefault) {
|
||||
await tx
|
||||
.update(companySecretProviderConfigs)
|
||||
.set({ isDefault: false, updatedAt: new Date() })
|
||||
.where(and(
|
||||
eq(companySecretProviderConfigs.companyId, existing.companyId),
|
||||
eq(companySecretProviderConfigs.provider, existing.provider),
|
||||
));
|
||||
}
|
||||
return tx
|
||||
.update(companySecretProviderConfigs)
|
||||
.set({
|
||||
displayName: patch.displayName?.trim() ?? existing.displayName,
|
||||
status,
|
||||
isDefault: status === "disabled" || status === "coming_soon" ? false : patch.isDefault ?? existing.isDefault,
|
||||
config: normalizedConfig,
|
||||
disabledAt: status === "disabled" ? existing.disabledAt ?? new Date() : null,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(companySecretProviderConfigs.id, id))
|
||||
.returning()
|
||||
.then((rows) => rows[0] ?? null);
|
||||
});
|
||||
},
|
||||
|
||||
disableProviderConfig: async (id: string) => {
|
||||
const existing = await getProviderConfigById(id);
|
||||
if (!existing) return null;
|
||||
return db
|
||||
.update(companySecretProviderConfigs)
|
||||
.set({
|
||||
status: "disabled",
|
||||
isDefault: false,
|
||||
disabledAt: existing.disabledAt ?? new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(companySecretProviderConfigs.id, id))
|
||||
.returning()
|
||||
.then((rows) => rows[0] ?? null);
|
||||
},
|
||||
|
||||
setDefaultProviderConfig: async (id: string) => {
|
||||
const existing = await getProviderConfigById(id);
|
||||
if (!existing) return null;
|
||||
if (existing.status === "coming_soon" || existing.status === "disabled") {
|
||||
throw unprocessable("Only ready or warning provider vaults can be default");
|
||||
}
|
||||
return db.transaction(async (tx) => {
|
||||
await tx
|
||||
.update(companySecretProviderConfigs)
|
||||
.set({ isDefault: false, updatedAt: new Date() })
|
||||
.where(and(
|
||||
eq(companySecretProviderConfigs.companyId, existing.companyId),
|
||||
eq(companySecretProviderConfigs.provider, existing.provider),
|
||||
));
|
||||
return tx
|
||||
.update(companySecretProviderConfigs)
|
||||
.set({ isDefault: true, updatedAt: new Date() })
|
||||
.where(eq(companySecretProviderConfigs.id, id))
|
||||
.returning()
|
||||
.then((rows) => rows[0] ?? null);
|
||||
});
|
||||
},
|
||||
|
||||
checkProviderConfigHealth: async (id: string) => {
|
||||
const existing = await getProviderConfigById(id);
|
||||
if (!existing) return null;
|
||||
const checkedAt = new Date();
|
||||
const health = providerConfigHealth({
|
||||
id: existing.id,
|
||||
provider: existing.provider as SecretProvider,
|
||||
status: existing.status as SecretProviderConfigStatus,
|
||||
config: existing.config ?? {},
|
||||
});
|
||||
await db
|
||||
.update(companySecretProviderConfigs)
|
||||
.set({
|
||||
healthStatus: health.status,
|
||||
healthCheckedAt: checkedAt,
|
||||
healthMessage: health.message,
|
||||
healthDetails: health.details as unknown as Record<string, unknown>,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(companySecretProviderConfigs.id, id));
|
||||
return { ...health, checkedAt };
|
||||
},
|
||||
|
||||
list: (companyId: string) =>
|
||||
db
|
||||
.select()
|
||||
@@ -381,6 +671,7 @@ export function secretService(db: Db) {
|
||||
input: {
|
||||
name: string;
|
||||
provider: SecretProvider;
|
||||
providerConfigId?: string | null;
|
||||
value?: string | null;
|
||||
key?: string | null;
|
||||
managedMode?: "paperclip_managed" | "external_reference";
|
||||
@@ -404,6 +695,7 @@ export function secretService(db: Db) {
|
||||
|
||||
const managedMode = input.managedMode ?? "paperclip_managed";
|
||||
const provider = getSecretProvider(input.provider);
|
||||
await assertProviderConfigForSecret(companyId, input.provider, input.providerConfigId);
|
||||
if (managedMode === "external_reference" && !input.externalRef?.trim()) {
|
||||
throw unprocessable("External reference secrets require externalRef");
|
||||
}
|
||||
@@ -444,6 +736,7 @@ export function secretService(db: Db) {
|
||||
key,
|
||||
name: input.name,
|
||||
provider: input.provider,
|
||||
providerConfigId: input.providerConfigId ?? null,
|
||||
status: "active",
|
||||
managedMode,
|
||||
externalRef: prepared.externalRef,
|
||||
@@ -554,6 +847,7 @@ export function secretService(db: Db) {
|
||||
name?: string;
|
||||
key?: string;
|
||||
status?: "active" | "disabled" | "archived" | "deleted";
|
||||
providerConfigId?: string | null;
|
||||
description?: string | null;
|
||||
externalRef?: string | null;
|
||||
providerMetadata?: Record<string, unknown> | null;
|
||||
@@ -584,6 +878,13 @@ export function secretService(db: Db) {
|
||||
if (secret.managedMode !== "external_reference" && patch.externalRef !== undefined) {
|
||||
throw unprocessable("Managed secrets cannot override externalRef");
|
||||
}
|
||||
if (patch.providerConfigId !== undefined) {
|
||||
await assertProviderConfigForSecret(
|
||||
secret.companyId,
|
||||
secret.provider as SecretProvider,
|
||||
patch.providerConfigId,
|
||||
);
|
||||
}
|
||||
|
||||
return db
|
||||
.update(companySecrets)
|
||||
@@ -591,6 +892,8 @@ export function secretService(db: Db) {
|
||||
key: nextKey,
|
||||
name: patch.name ?? secret.name,
|
||||
status: patch.status ?? secret.status,
|
||||
providerConfigId:
|
||||
patch.providerConfigId === undefined ? secret.providerConfigId : patch.providerConfigId,
|
||||
description:
|
||||
patch.description === undefined ? secret.description : patch.description,
|
||||
externalRef:
|
||||
|
||||
Reference in new issue
Block a user