mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 09:07:46 +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
@@ -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