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

+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: