mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-24 01:41:42 +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);
|
||||
|
||||
Reference in new issue
Block a user