Implement provider vault data model and API

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-05-07 18:59:05 -05:00
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`),
}),
);
+3 -1
View File
@@ -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),
}),
+1
View File
@@ -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";
+1
View File
@@ -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`,
+18
View File
@@ -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];
+17
View File
@@ -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,
+4
View File
@@ -244,6 +244,10 @@ export type {
EnvBinding,
AgentEnvConfig,
CompanySecret,
CompanySecretProviderConfig,
SecretProviderConfigPayload,
SecretProviderConfigHealthDetails,
SecretProviderConfigHealthResponse,
CompanySecretBinding,
CompanySecretVersion,
SecretAccessEvent,
+80 -1
View File
@@ -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;
+9
View File
@@ -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";
+114
View File
@@ -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>;
+124 -11
View File
@@ -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");
});
});
+65 -1
View File
@@ -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);
+195
View File
@@ -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,
+304 -1
View File
@@ -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: