Implement AWS remote secret import backend

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-05-07 18:59:05 -05:00
1 parent e38bf7f05d
commit 02a5f13044
14 files changed
+1074

No files matched your search

+103
View File
@@ -0,0 +1,103 @@
---
title: Secrets Remote Import
summary: AWS Secrets Manager metadata-only remote import API
---
Remote import lets the board link existing AWS Secrets Manager entries as
Paperclip `external_reference` secrets without copying plaintext into
Paperclip.
Both routes are board-only and company-scoped. The selected provider vault must
belong to the company, use `aws_secrets_manager`, and have a selectable status
(`ready` or `warning`). Disabled, coming-soon, or cross-company vaults are
rejected.
## Preview Remote AWS Secrets
```
POST /api/companies/{companyId}/secrets/remote-import/preview
{
"providerConfigId": "<aws-vault-uuid>",
"query": "stripe",
"nextToken": "optional-provider-page-token",
"pageSize": 50
}
```
The backend calls AWS Secrets Manager `ListSecrets` only. It never calls
`GetSecretValue`, never requests `SecretString`, and never returns plaintext.
Response:
```json
{
"providerConfigId": "<aws-vault-uuid>",
"provider": "aws_secrets_manager",
"nextToken": null,
"candidates": [
{
"externalRef": "arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/stripe",
"remoteName": "prod/stripe",
"name": "prod/stripe",
"key": "prod-stripe",
"providerVersionRef": null,
"providerMetadata": { "name": "prod/stripe" },
"status": "ready",
"importable": true,
"conflicts": []
}
]
}
```
Candidate `status` values:
- `ready`: no existing exact external reference and no name/key collision.
- `duplicate`: an existing secret already has the exact provider `externalRef`.
- `conflict`: the suggested Paperclip `name` or `key` is already in use.
## Import Remote AWS Secret References
```
POST /api/companies/{companyId}/secrets/remote-import
{
"providerConfigId": "<aws-vault-uuid>",
"secrets": [
{
"externalRef": "arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/stripe",
"name": "Stripe production key",
"key": "stripe-production-key",
"providerVersionRef": null,
"providerMetadata": { "name": "prod/stripe" }
}
]
}
```
The import response is row-level. Ready rows become active
`external_reference` secrets with version metadata only. Exact-reference
duplicates and name/key conflicts are skipped without failing the whole request.
```json
{
"providerConfigId": "<aws-vault-uuid>",
"provider": "aws_secrets_manager",
"importedCount": 1,
"skippedCount": 1,
"errorCount": 0,
"results": [
{
"externalRef": "arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/stripe",
"name": "Stripe production key",
"key": "stripe-production-key",
"status": "imported",
"reason": null,
"secretId": "<paperclip-secret-id>",
"conflicts": []
}
]
}
```
Activity logs record aggregate counts and provider/vault ids only, not remote
secret names, ARNs, tags, or values.
+13
View File
@@ -543,6 +543,13 @@ export type {
CompanySecretUsageBinding,
CompanySecretVersion,
SecretAccessEvent,
RemoteSecretImportCandidate,
RemoteSecretImportCandidateStatus,
RemoteSecretImportConflict,
RemoteSecretImportPreviewResult,
RemoteSecretImportResult,
RemoteSecretImportRowResult,
RemoteSecretImportRowStatus,
SecretAccessOutcome,
SecretBindingTargetType,
SecretManagedMode,
@@ -847,6 +854,9 @@ export {
createSecretSchema,
createSecretProviderConfigSchema,
updateSecretProviderConfigSchema,
remoteSecretImportPreviewSchema,
remoteSecretImportSchema,
remoteSecretImportSelectionSchema,
localEncryptedProviderConfigSchema,
awsSecretsManagerProviderConfigSchema,
gcpSecretManagerProviderConfigSchema,
@@ -870,6 +880,9 @@ export {
type CreateSecret,
type CreateSecretProviderConfig,
type UpdateSecretProviderConfig,
type RemoteSecretImportPreview,
type RemoteSecretImport,
type RemoteSecretImportSelection,
type RotateSecret,
type UpdateSecret,
type CreateRoutine,
+7
View File
@@ -253,6 +253,13 @@ export type {
CompanySecretUsageBinding,
CompanySecretVersion,
SecretAccessEvent,
RemoteSecretImportCandidate,
RemoteSecretImportCandidateStatus,
RemoteSecretImportConflict,
RemoteSecretImportPreviewResult,
RemoteSecretImportResult,
RemoteSecretImportRowResult,
RemoteSecretImportRowStatus,
SecretAccessOutcome,
SecretBindingTargetType,
SecretManagedMode,
+48
View File
@@ -194,3 +194,51 @@ export interface SecretAccessEvent {
errorCode: string | null;
createdAt: Date;
}
export type RemoteSecretImportCandidateStatus = "ready" | "duplicate" | "conflict";
export interface RemoteSecretImportConflict {
type: "exact_reference" | "name" | "key";
message: string;
existingSecretId?: string;
}
export interface RemoteSecretImportCandidate {
externalRef: string;
remoteName: string;
name: string;
key: string;
providerVersionRef: string | null;
providerMetadata: Record<string, unknown> | null;
status: RemoteSecretImportCandidateStatus;
importable: boolean;
conflicts: RemoteSecretImportConflict[];
}
export interface RemoteSecretImportPreviewResult {
providerConfigId: string;
provider: SecretProvider;
nextToken: string | null;
candidates: RemoteSecretImportCandidate[];
}
export type RemoteSecretImportRowStatus = "imported" | "skipped" | "error";
export interface RemoteSecretImportRowResult {
externalRef: string;
name: string;
key: string;
status: RemoteSecretImportRowStatus;
reason: string | null;
secretId: string | null;
conflicts: RemoteSecretImportConflict[];
}
export interface RemoteSecretImportResult {
providerConfigId: string;
provider: SecretProvider;
importedCount: number;
skippedCount: number;
errorCount: number;
results: RemoteSecretImportRowResult[];
}
+6
View File
@@ -284,6 +284,9 @@ export {
createSecretSchema,
createSecretProviderConfigSchema,
updateSecretProviderConfigSchema,
remoteSecretImportPreviewSchema,
remoteSecretImportSchema,
remoteSecretImportSelectionSchema,
localEncryptedProviderConfigSchema,
awsSecretsManagerProviderConfigSchema,
gcpSecretManagerProviderConfigSchema,
@@ -297,6 +300,9 @@ export {
type CreateSecret,
type CreateSecretProviderConfig,
type UpdateSecretProviderConfig,
type RemoteSecretImportPreview,
type RemoteSecretImport,
type RemoteSecretImportSelection,
type RotateSecret,
type UpdateSecret,
} from "./secret.js";
@@ -2,6 +2,8 @@ import { describe, expect, it } from "vitest";
import {
createSecretProviderConfigSchema,
createSecretSchema,
remoteSecretImportPreviewSchema,
remoteSecretImportSchema,
secretProviderConfigPayloadSchema,
updateSecretProviderConfigSchema,
} from "./secret.js";
@@ -100,4 +102,50 @@ describe("secret validators", () => {
}),
).toThrow(/origin-only HTTP\(S\) URL/i);
});
it("validates AWS remote import preview and import payloads", () => {
expect(
remoteSecretImportPreviewSchema.parse({
providerConfigId: "11111111-1111-4111-8111-111111111111",
query: "openai",
pageSize: 50,
}),
).toEqual({
providerConfigId: "11111111-1111-4111-8111-111111111111",
query: "openai",
pageSize: 50,
});
expect(
remoteSecretImportSchema.parse({
providerConfigId: "11111111-1111-4111-8111-111111111111",
secrets: [
{
externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/openai",
name: "OpenAI API key",
key: "OPENAI_API_KEY",
providerMetadata: { name: "prod/openai" },
},
],
}),
).toMatchObject({
providerConfigId: "11111111-1111-4111-8111-111111111111",
secrets: [expect.objectContaining({ key: "OPENAI_API_KEY" })],
});
});
it("caps AWS remote import paging and row counts", () => {
expect(() =>
remoteSecretImportPreviewSchema.parse({
providerConfigId: "11111111-1111-4111-8111-111111111111",
pageSize: 101,
}),
).toThrow();
expect(() =>
remoteSecretImportSchema.parse({
providerConfigId: "11111111-1111-4111-8111-111111111111",
secrets: [],
}),
).toThrow();
});
});
+25
View File
@@ -252,3 +252,28 @@ export const updateSecretProviderConfigSchema = z.object({
});
export type UpdateSecretProviderConfig = z.infer<typeof updateSecretProviderConfigSchema>;
export const remoteSecretImportPreviewSchema = z.object({
providerConfigId: z.string().uuid(),
query: z.string().trim().max(200).optional().nullable(),
nextToken: z.string().trim().min(1).max(4096).optional().nullable(),
pageSize: z.number().int().min(1).max(100).optional(),
});
export type RemoteSecretImportPreview = z.infer<typeof remoteSecretImportPreviewSchema>;
export const remoteSecretImportSelectionSchema = z.object({
externalRef: z.string().trim().min(1).max(2048),
name: z.string().trim().min(1).max(160).optional().nullable(),
key: z.string().trim().min(1).max(120).regex(/^[a-zA-Z0-9_.-]+$/).optional().nullable(),
providerVersionRef: z.string().trim().min(1).max(512).optional().nullable(),
providerMetadata: z.record(z.unknown()).optional().nullable(),
});
export const remoteSecretImportSchema = z.object({
providerConfigId: z.string().uuid(),
secrets: z.array(remoteSecretImportSelectionSchema).min(1).max(100),
});
export type RemoteSecretImportSelection = z.infer<typeof remoteSecretImportSelectionSchema>;
export type RemoteSecretImport = z.infer<typeof remoteSecretImportSchema>;
@@ -283,6 +283,86 @@ describe("awsSecretsManagerProvider", () => {
expect(prepared.valueSha256).toBeTruthy();
});
it("lists remote AWS secrets with metadata only and never resolves plaintext", async () => {
const calls: Array<{ op: string; input: Record<string, unknown> }> = [];
const provider = createAwsSecretsManagerProvider({
config: {
region: "us-east-1",
endpoint: "https://secretsmanager.us-east-1.amazonaws.com",
deploymentId: "prod-use1",
prefix: "paperclip",
kmsKeyId: "arn:aws:kms:us-east-1:123456789012:key/test",
environmentTag: "production",
providerOwnerTag: "paperclip",
deleteRecoveryWindowDays: 30,
},
gateway: {
async createSecret() {
throw new Error("not used");
},
async putSecretValue() {
throw new Error("not used");
},
async getSecretValue() {
throw new Error("GetSecretValue must not be used for remote import preview");
},
async deleteSecret() {
throw new Error("not used");
},
async listSecrets(input) {
calls.push({ op: "listSecrets", input });
return {
NextToken: "token-2",
SecretList: [
{
ARN: "arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/openai",
Name: "prod/openai",
Description: "OpenAI API key",
CreatedDate: new Date("2026-05-06T00:00:00.000Z"),
Tags: [{ Key: "team", Value: "platform" }],
},
],
};
},
},
});
const listed = await provider.listRemoteSecrets?.({
query: "openai",
nextToken: "token-1",
pageSize: 25,
});
expect(calls).toEqual([
{
op: "listSecrets",
input: {
MaxResults: 25,
NextToken: "token-1",
IncludePlannedDeletion: false,
Filters: [{ Key: "all", Values: ["openai"] }],
},
},
]);
expect(listed).toEqual({
nextToken: "token-2",
secrets: [
{
externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/openai",
name: "prod/openai",
providerVersionRef: null,
metadata: expect.objectContaining({
arn: "arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/openai",
name: "prod/openai",
description: "OpenAI API key",
createdDate: "2026-05-06T00:00:00.000Z",
}),
},
],
});
expect(JSON.stringify(listed)).not.toContain("SecretString");
});
it("resolves AWS secret values by provider version reference", async () => {
const calls: Array<{ op: string; input: Record<string, unknown> }> = [];
const provider = createAwsSecretsManagerProvider({
+123
View File
@@ -15,6 +15,8 @@ const mockSecretService = vi.hoisted(() => ({
setDefaultProviderConfig: vi.fn(),
checkProviderConfigHealth: vi.fn(),
create: vi.fn(),
previewRemoteImport: vi.fn(),
importRemoteSecrets: vi.fn(),
}));
const mockLogActivity = vi.hoisted(() => vi.fn());
@@ -234,4 +236,125 @@ describe("secret routes", () => {
}));
expect(JSON.stringify(mockLogActivity.mock.calls)).not.toContain("accessKey");
});
it("rejects remote import preview for non-board actors", async () => {
const res = await request(createApp({
type: "agent",
agentId: "agent-1",
companyId: "company-1",
})).post("/api/companies/company-1/secrets/remote-import/preview").send({
providerConfigId: "11111111-1111-4111-8111-111111111111",
});
expect(res.status).toBe(403);
expect(mockSecretService.previewRemoteImport).not.toHaveBeenCalled();
});
it("previews remote imports and logs only aggregate metadata", async () => {
mockSecretService.previewRemoteImport.mockResolvedValue({
providerConfigId: "11111111-1111-4111-8111-111111111111",
provider: "aws_secrets_manager",
nextToken: null,
candidates: [
{
externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/openai",
remoteName: "prod/openai",
name: "openai",
key: "openai",
providerVersionRef: null,
providerMetadata: { description: "OpenAI API key" },
status: "ready",
importable: true,
conflicts: [],
},
],
});
const res = await request(createApp())
.post("/api/companies/company-1/secrets/remote-import/preview")
.send({
providerConfigId: "11111111-1111-4111-8111-111111111111",
query: "openai",
pageSize: 25,
});
expect(res.status).toBe(200);
expect(mockSecretService.previewRemoteImport).toHaveBeenCalledWith("company-1", {
providerConfigId: "11111111-1111-4111-8111-111111111111",
query: "openai",
nextToken: undefined,
pageSize: 25,
});
expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({
action: "secret.remote_import.previewed",
details: {
provider: "aws_secrets_manager",
candidateCount: 1,
readyCount: 1,
duplicateCount: 0,
conflictCount: 0,
},
}));
expect(JSON.stringify(mockLogActivity.mock.calls)).not.toContain("prod/openai");
});
it("imports remote references and logs aggregate row counts", async () => {
mockSecretService.importRemoteSecrets.mockResolvedValue({
providerConfigId: "11111111-1111-4111-8111-111111111111",
provider: "aws_secrets_manager",
importedCount: 1,
skippedCount: 0,
errorCount: 0,
results: [
{
externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/openai",
name: "OpenAI API key",
key: "openai-api-key",
status: "imported",
reason: null,
secretId: "22222222-2222-4222-8222-222222222222",
conflicts: [],
},
],
});
const res = await request(createApp())
.post("/api/companies/company-1/secrets/remote-import")
.send({
providerConfigId: "11111111-1111-4111-8111-111111111111",
secrets: [
{
externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/openai",
name: "OpenAI API key",
key: "openai-api-key",
},
],
});
expect(res.status).toBe(200);
expect(mockSecretService.importRemoteSecrets).toHaveBeenCalledWith(
"company-1",
{
providerConfigId: "11111111-1111-4111-8111-111111111111",
secrets: [
{
externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/openai",
name: "OpenAI API key",
key: "openai-api-key",
},
],
},
{ userId: "user-1", agentId: null },
);
expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({
action: "secret.remote_import.completed",
details: {
provider: "aws_secrets_manager",
importedCount: 1,
skippedCount: 0,
errorCount: 0,
},
}));
expect(JSON.stringify(mockLogActivity.mock.calls)).not.toContain("prod/openai");
});
});
@@ -364,6 +364,177 @@ describeEmbeddedPostgres("secretService", () => {
expect(JSON.stringify(resolveSpy.mock.calls[0]?.[0])).not.toContain("resolved-secret");
});
it("previews AWS remote import candidates with duplicate and collision enrichment", async () => {
const companyId = await seedCompany();
const svc = secretService(db);
const awsVault = await svc.createProviderConfig(companyId, {
provider: "aws_secrets_manager",
displayName: "AWS production",
config: { region: "us-east-1", namespace: "prod-use1" },
});
const duplicate = await svc.create(companyId, {
name: "Existing duplicate",
provider: "aws_secrets_manager",
providerConfigId: awsVault.id,
managedMode: "external_reference",
externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/duplicate",
});
const nameConflict = await svc.create(companyId, {
name: "Prod Conflict",
provider: "local_encrypted",
value: "runtime-secret",
});
const listSpy = vi.spyOn(awsSecretsManagerProvider, "listRemoteSecrets").mockResolvedValue({
nextToken: "next-page",
secrets: [
{
externalRef: duplicate.externalRef!,
name: "prod/duplicate",
providerVersionRef: null,
metadata: { arn: duplicate.externalRef },
},
{
externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/conflict",
name: nameConflict.name,
providerVersionRef: null,
metadata: { arn: "arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/conflict" },
},
{
externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/ready",
name: "prod/ready",
providerVersionRef: null,
metadata: { arn: "arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/ready" },
},
],
});
const preview = await svc.previewRemoteImport(companyId, {
providerConfigId: awsVault.id,
query: "prod",
pageSize: 25,
});
expect(listSpy).toHaveBeenCalledWith(expect.objectContaining({
providerConfig: expect.objectContaining({ id: awsVault.id }),
query: "prod",
pageSize: 25,
}));
expect(preview.nextToken).toBe("next-page");
expect(preview.candidates.map((candidate) => candidate.status)).toEqual([
"duplicate",
"conflict",
"ready",
]);
expect(preview.candidates[0]?.conflicts[0]).toMatchObject({
type: "exact_reference",
existingSecretId: duplicate.id,
});
expect(preview.candidates[1]?.conflicts[0]).toMatchObject({
type: "name",
existingSecretId: nameConflict.id,
});
expect(preview.candidates[2]).toMatchObject({
importable: true,
name: "prod/ready",
key: "prod-ready",
});
});
it("imports AWS remote references row-by-row without fetching plaintext", async () => {
const companyId = await seedCompany();
const svc = secretService(db);
const awsVault = await svc.createProviderConfig(companyId, {
provider: "aws_secrets_manager",
displayName: "AWS production",
config: { region: "us-east-1", namespace: "prod-use1" },
});
const duplicate = await svc.create(companyId, {
name: "Existing duplicate",
provider: "aws_secrets_manager",
providerConfigId: awsVault.id,
managedMode: "external_reference",
externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/duplicate",
});
const resolveSpy = vi.spyOn(awsSecretsManagerProvider, "resolveVersion");
const result = await svc.importRemoteSecrets(
companyId,
{
providerConfigId: awsVault.id,
secrets: [
{
externalRef: duplicate.externalRef!,
name: "Existing duplicate",
key: "existing-duplicate",
},
{
externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/openai",
name: "OpenAI API key",
key: "openai-api-key",
providerMetadata: { arn: "arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/openai" },
},
],
},
{ userId: "user-1" },
);
expect(result.importedCount).toBe(1);
expect(result.skippedCount).toBe(1);
expect(result.results.map((row) => row.status)).toEqual(["skipped", "imported"]);
expect(result.results[0]).toMatchObject({
reason: "exact_reference_duplicate",
conflicts: [expect.objectContaining({ type: "exact_reference", existingSecretId: duplicate.id })],
});
expect(resolveSpy).not.toHaveBeenCalled();
const imported = await db
.select()
.from(companySecrets)
.where(eq(companySecrets.key, "openai-api-key"))
.then((rows) => rows[0]);
expect(imported).toMatchObject({
companyId,
provider: "aws_secrets_manager",
providerConfigId: awsVault.id,
managedMode: "external_reference",
externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/openai",
createdByUserId: "user-1",
});
const versions = await db
.select()
.from(companySecretVersions)
.where(eq(companySecretVersions.secretId, imported!.id));
expect(versions).toHaveLength(1);
expect(JSON.stringify(versions[0])).not.toContain("runtime-secret");
expect(JSON.stringify(versions[0])).not.toContain("sk-");
});
it("rejects remote import for disabled or cross-company provider vaults", async () => {
const companyA = await seedCompany("A");
const companyB = await seedCompany("B");
const svc = secretService(db);
const disabledVault = await svc.createProviderConfig(companyA, {
provider: "aws_secrets_manager",
displayName: "AWS disabled",
status: "disabled",
config: { region: "us-east-1" },
});
const foreignVault = await svc.createProviderConfig(companyB, {
provider: "aws_secrets_manager",
displayName: "AWS foreign",
config: { region: "us-east-1" },
});
await expect(
svc.previewRemoteImport(companyA, { providerConfigId: disabledVault.id }),
).rejects.toThrow(/disabled/i);
await expect(
svc.previewRemoteImport(companyA, { providerConfigId: foreignVault.id }),
).rejects.toThrow(/same company/i);
});
it("rejects externalRef overrides on managed secrets", async () => {
const companyId = await seedCompany();
const svc = secretService(db);
+73
View File
@@ -3,6 +3,8 @@ import type { Db } from "@paperclipai/db";
import {
createSecretProviderConfigSchema,
createSecretSchema,
remoteSecretImportPreviewSchema,
remoteSecretImportSchema,
rotateSecretSchema,
updateSecretProviderConfigSchema,
updateSecretSchema,
@@ -266,6 +268,77 @@ export function secretRoutes(db: Db) {
res.status(201).json(created);
});
router.post(
"/companies/:companyId/secrets/remote-import/preview",
validate(remoteSecretImportPreviewSchema),
async (req, res) => {
assertBoard(req);
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
const preview = await svc.previewRemoteImport(companyId, {
providerConfigId: req.body.providerConfigId,
query: req.body.query,
nextToken: req.body.nextToken,
pageSize: req.body.pageSize,
});
await logActivity(db, {
companyId,
actorType: "user",
actorId: req.actor.userId ?? "board",
action: "secret.remote_import.previewed",
entityType: "secret_provider_config",
entityId: preview.providerConfigId,
details: {
provider: preview.provider,
candidateCount: preview.candidates.length,
readyCount: preview.candidates.filter((candidate) => candidate.status === "ready").length,
duplicateCount: preview.candidates.filter((candidate) => candidate.status === "duplicate").length,
conflictCount: preview.candidates.filter((candidate) => candidate.status === "conflict").length,
},
});
res.json(preview);
},
);
router.post(
"/companies/:companyId/secrets/remote-import",
validate(remoteSecretImportSchema),
async (req, res) => {
assertBoard(req);
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
const result = await svc.importRemoteSecrets(
companyId,
{
providerConfigId: req.body.providerConfigId,
secrets: req.body.secrets,
},
{ userId: req.actor.userId ?? "board", agentId: null },
);
await logActivity(db, {
companyId,
actorType: "user",
actorId: req.actor.userId ?? "board",
action: "secret.remote_import.completed",
entityType: "secret_provider_config",
entityId: result.providerConfigId,
details: {
provider: result.provider,
importedCount: result.importedCount,
skippedCount: result.skippedCount,
errorCount: result.errorCount,
},
});
res.json(result);
},
);
router.post("/secrets/:id/rotate", validate(rotateSecretSchema), async (req, res) => {
assertBoard(req);
const id = req.params.id as string;
@@ -7,6 +7,7 @@ import type { DeploymentMode } from "@paperclipai/shared";
import { conflict, notFound, unprocessable } from "../errors.js";
import type {
PreparedSecretVersion,
RemoteSecretListResult,
SecretProviderHealthCheck,
SecretProviderModule,
SecretProviderValidationResult,
@@ -48,6 +49,18 @@ interface AwsSecretsManagerTag {
Value: string;
}
interface AwsSecretsManagerListSecretEntry {
ARN?: string;
Name?: string;
Description?: string;
KmsKeyId?: string;
CreatedDate?: string | number | Date;
LastAccessedDate?: string | number | Date;
LastChangedDate?: string | number | Date;
DeletedDate?: string | number | Date;
Tags?: AwsSecretsManagerTag[];
}
type ManagedSecretNamespaceContext = Pick<SecretProviderWriteContext, "companyId" | "secretKey">;
interface AwsSecretsManagerGateway {
@@ -84,6 +97,18 @@ interface AwsSecretsManagerGateway {
SecretId: string;
RecoveryWindowInDays: number;
}): Promise<unknown>;
listSecrets?(input: {
MaxResults?: number;
NextToken?: string;
Filters?: Array<{
Key: "all" | "name" | "description" | "tag-key" | "tag-value" | "primary-region" | "owning-service";
Values: string[];
}>;
IncludePlannedDeletion?: boolean;
}): Promise<{
SecretList?: AwsSecretsManagerListSecretEntry[];
NextToken?: string;
}>;
}
function sha256Hex(value: string): string {
@@ -381,6 +406,26 @@ function createManagedMaterial(secretId: string, versionId: string | null): AwsS
};
}
function serializeAwsDate(value: string | number | Date | undefined): string | null {
if (value === undefined) return null;
const date = value instanceof Date ? value : new Date(value);
return Number.isNaN(date.getTime()) ? null : date.toISOString();
}
function createRemoteSecretMetadata(entry: AwsSecretsManagerListSecretEntry): Record<string, unknown> {
return {
arn: entry.ARN ?? null,
name: entry.Name ?? null,
description: entry.Description ?? null,
kmsKeyId: entry.KmsKeyId ?? null,
createdDate: serializeAwsDate(entry.CreatedDate),
lastAccessedDate: serializeAwsDate(entry.LastAccessedDate),
lastChangedDate: serializeAwsDate(entry.LastChangedDate),
deletedDate: serializeAwsDate(entry.DeletedDate),
tags: entry.Tags ?? [],
};
}
function asAwsSecretsManagerMaterial(value: StoredSecretVersionMaterial): AwsSecretsManagerMaterial {
if (
value &&
@@ -465,6 +510,21 @@ class AwsSecretsManagerJsonGateway implements AwsSecretsManagerGateway {
return this.call("DeleteSecret", input);
}
listSecrets(input: {
MaxResults?: number;
NextToken?: string;
Filters?: Array<{
Key: "all" | "name" | "description" | "tag-key" | "tag-value" | "primary-region" | "owning-service";
Values: string[];
}>;
IncludePlannedDeletion?: boolean;
}) {
return this.call<{
SecretList?: AwsSecretsManagerListSecretEntry[];
NextToken?: string;
}>("ListSecrets", input);
}
private async call<T>(operation: string, payload: Record<string, unknown>): Promise<T> {
const body = JSON.stringify(payload);
const request = new HttpRequest({
@@ -682,6 +742,40 @@ export function createAwsSecretsManagerProvider(
async linkExternalSecret(input) {
return createExternalReferenceMaterial(input.externalRef, input.providerVersionRef ?? null);
},
async listRemoteSecrets(input): Promise<RemoteSecretListResult> {
const config = resolveConfig(input.providerConfig);
const gateway = resolveGateway(config);
const query = input.query?.trim();
const pageSize =
input.pageSize && Number.isFinite(input.pageSize)
? Math.min(Math.max(Math.trunc(input.pageSize), 1), 100)
: 50;
try {
if (!gateway.listSecrets) {
throw new Error("ListSecrets gateway operation is unavailable");
}
const listed = await gateway.listSecrets({
MaxResults: pageSize,
NextToken: input.nextToken?.trim() || undefined,
IncludePlannedDeletion: false,
Filters: query ? [{ Key: "all", Values: [query] }] : undefined,
});
return {
nextToken: listed.NextToken ?? null,
secrets: (listed.SecretList ?? [])
.filter((entry) => Boolean(entry.ARN ?? entry.Name))
.map((entry) => ({
externalRef: entry.ARN ?? entry.Name ?? "",
name: entry.Name ?? entry.ARN ?? "",
providerVersionRef: null,
metadata: createRemoteSecretMetadata(entry),
})),
};
} catch (error) {
normalizeAwsError("listSecrets", error);
}
},
async resolveVersion(input) {
const config = resolveConfig(input.providerConfig);
const gateway = resolveGateway(config);
+18
View File
@@ -29,6 +29,18 @@ export interface PreparedSecretVersion {
providerVersionRef?: string | null;
}
export interface RemoteSecretListEntry {
externalRef: string;
name: string;
providerVersionRef?: string | null;
metadata?: Record<string, unknown> | null;
}
export interface RemoteSecretListResult {
secrets: RemoteSecretListEntry[];
nextToken?: string | null;
}
export interface SecretProviderRuntimeContext {
companyId: string;
secretId: string;
@@ -76,6 +88,12 @@ export interface SecretProviderModule {
context?: SecretProviderWriteContext;
providerConfig?: SecretProviderVaultRuntimeConfig | null;
}): Promise<PreparedSecretVersion>;
listRemoteSecrets?(input: {
providerConfig?: SecretProviderVaultRuntimeConfig | null;
query?: string | null;
nextToken?: string | null;
pageSize?: number;
}): Promise<RemoteSecretListResult>;
resolveVersion(input: {
material: StoredSecretVersionMaterial;
externalRef: string | null;
+265
View File
@@ -17,6 +17,9 @@ import type {
AgentEnvConfig,
CompanySecretBindingTarget,
EnvBinding,
RemoteSecretImportCandidate,
RemoteSecretImportConflict,
RemoteSecretImportRowResult,
SecretBindingTargetType,
SecretProvider,
SecretProviderConfigHealthResponse,
@@ -99,6 +102,13 @@ function normalizeSecretKey(input: string) {
.slice(0, 120);
}
function deriveSecretNameFromExternalRef(externalRef: string) {
const trimmed = externalRef.trim();
const arnMatch = /^arn:[^:]+:secretsmanager:[^:]*:[^:]*:secret:(.+)$/i.exec(trimmed);
const name = arnMatch?.[1] ?? trimmed;
return name.split("/").filter(Boolean).at(-1) ?? name;
}
function canonicalizeBinding(binding: EnvBinding): CanonicalEnvBinding {
if (typeof binding === "string") {
return { type: "plain", value: binding };
@@ -672,6 +682,71 @@ export function secretService(db: Db) {
return targetMap;
}
async function buildRemoteImportConflictMaps(companyId: string, provider: SecretProvider) {
const existingSecrets = await db
.select({
id: companySecrets.id,
name: companySecrets.name,
key: companySecrets.key,
provider: companySecrets.provider,
externalRef: companySecrets.externalRef,
})
.from(companySecrets)
.where(eq(companySecrets.companyId, companyId));
return {
byExternalRef: new Map(
existingSecrets
.filter((secret) => secret.provider === provider && typeof secret.externalRef === "string" && secret.externalRef.trim())
.map((secret) => [secret.externalRef!.trim(), secret]),
),
byName: new Map(existingSecrets.map((secret) => [secret.name, secret])),
byKey: new Map(existingSecrets.map((secret) => [secret.key, secret])),
};
}
function remoteImportConflictsFor(input: {
externalRef: string;
name: string;
key: string;
maps: Awaited<ReturnType<typeof buildRemoteImportConflictMaps>>;
}): RemoteSecretImportConflict[] {
const conflicts: RemoteSecretImportConflict[] = [];
const duplicate = input.maps.byExternalRef.get(input.externalRef);
if (duplicate) {
conflicts.push({
type: "exact_reference",
existingSecretId: duplicate.id,
message: "An existing secret already links this exact provider reference.",
});
return conflicts;
}
const nameConflict = input.maps.byName.get(input.name);
if (nameConflict) {
conflicts.push({
type: "name",
existingSecretId: nameConflict.id,
message: `Secret name already exists: ${input.name}`,
});
}
const keyConflict = input.maps.byKey.get(input.key);
if (keyConflict) {
conflicts.push({
type: "key",
existingSecretId: keyConflict.id,
message: `Secret key already exists: ${input.key}`,
});
}
return conflicts;
}
async function getRemoteImportProviderConfig(companyId: string, providerConfigId: string) {
const providerConfig = await getProviderConfigById(providerConfigId);
if (!providerConfig) throw notFound("Provider vault not found");
const provider = providerConfig.provider as SecretProvider;
assertSelectableProviderConfig(providerConfig, companyId, provider);
return { providerConfig, provider, runtimeConfig: toProviderVaultRuntimeConfig(providerConfig) };
}
return {
listProviders: () => listSecretProviders(),
@@ -910,6 +985,196 @@ export function secretService(db: Db) {
.where(and(eq(secretAccessEvents.companyId, companyId), eq(secretAccessEvents.secretId, secretId)))
.orderBy(desc(secretAccessEvents.createdAt)),
previewRemoteImport: async (
companyId: string,
input: {
providerConfigId: string;
query?: string | null;
nextToken?: string | null;
pageSize?: number;
},
) => {
const { providerConfig, provider: providerId, runtimeConfig } = await getRemoteImportProviderConfig(
companyId,
input.providerConfigId,
);
const provider = getSecretProvider(providerId);
if (!provider.listRemoteSecrets) {
throw unprocessable(`${providerId} provider does not support remote import listing`);
}
const listed = await provider.listRemoteSecrets({
providerConfig: runtimeConfig,
query: input.query,
nextToken: input.nextToken,
pageSize: input.pageSize,
});
const maps = await buildRemoteImportConflictMaps(companyId, providerId);
const candidates: RemoteSecretImportCandidate[] = listed.secrets.map((remote) => {
const externalRef = remote.externalRef.trim();
const remoteName = remote.name.trim() || deriveSecretNameFromExternalRef(externalRef);
const name = remoteName || deriveSecretNameFromExternalRef(externalRef);
const key = normalizeSecretKey(name);
const conflicts = remoteImportConflictsFor({ externalRef, name, key, maps });
const hasDuplicate = conflicts.some((conflict) => conflict.type === "exact_reference");
const hasConflict = conflicts.length > 0;
return {
externalRef,
remoteName,
name,
key,
providerVersionRef: remote.providerVersionRef ?? null,
providerMetadata: remote.metadata ?? null,
status: hasDuplicate ? "duplicate" : hasConflict ? "conflict" : "ready",
importable: !hasConflict,
conflicts,
};
});
return {
providerConfigId: providerConfig.id,
provider: providerId,
nextToken: listed.nextToken ?? null,
candidates,
};
},
importRemoteSecrets: async (
companyId: string,
input: {
providerConfigId: string;
secrets: Array<{
externalRef: string;
name?: string | null;
key?: string | null;
providerVersionRef?: string | null;
providerMetadata?: Record<string, unknown> | null;
}>;
},
actor?: { userId?: string | null; agentId?: string | null },
) => {
const { providerConfig, provider: providerId, runtimeConfig } = await getRemoteImportProviderConfig(
companyId,
input.providerConfigId,
);
const provider = getSecretProvider(providerId);
if (provider.descriptor().supportsExternalReferences === false) {
throw unprocessable(`${providerId} provider does not support linked external references`);
}
const maps = await buildRemoteImportConflictMaps(companyId, providerId);
const results: RemoteSecretImportRowResult[] = [];
for (const selection of input.secrets) {
const externalRef = selection.externalRef.trim();
const name = selection.name?.trim() || deriveSecretNameFromExternalRef(externalRef);
const key = normalizeSecretKey(selection.key?.trim() || name);
const conflicts = remoteImportConflictsFor({ externalRef, name, key, maps });
if (!key) {
results.push({
externalRef,
name,
key,
status: "error",
reason: "Secret key is required",
secretId: null,
conflicts,
});
continue;
}
if (conflicts.length > 0) {
results.push({
externalRef,
name,
key,
status: "skipped",
reason: conflicts.some((conflict) => conflict.type === "exact_reference")
? "exact_reference_duplicate"
: "name_or_key_conflict",
secretId: null,
conflicts,
});
continue;
}
try {
const prepared = await provider.linkExternalSecret({
externalRef,
providerVersionRef: selection.providerVersionRef ?? null,
providerConfig: runtimeConfig,
context: {
companyId,
secretKey: key,
secretName: name,
version: 1,
},
});
const secret = await db.transaction(async (tx) => {
const inserted = await tx
.insert(companySecrets)
.values({
companyId,
key,
name,
provider: providerId,
providerConfigId: providerConfig.id,
status: "active",
managedMode: "external_reference",
externalRef: prepared.externalRef,
providerMetadata: selection.providerMetadata ?? null,
latestVersion: 1,
description: null,
lastRotatedAt: new Date(),
createdByAgentId: actor?.agentId ?? null,
createdByUserId: actor?.userId ?? null,
})
.returning()
.then((rows) => rows[0]);
await tx.insert(companySecretVersions).values({
secretId: inserted.id,
version: 1,
material: prepared.material,
valueSha256: prepared.valueSha256,
fingerprintSha256: prepared.fingerprintSha256 ?? prepared.valueSha256,
providerVersionRef: prepared.providerVersionRef ?? null,
status: "current",
createdByAgentId: actor?.agentId ?? null,
createdByUserId: actor?.userId ?? null,
});
return inserted;
});
maps.byExternalRef.set(externalRef, secret);
maps.byName.set(name, secret);
maps.byKey.set(key, secret);
results.push({
externalRef,
name,
key,
status: "imported",
reason: null,
secretId: secret.id,
conflicts: [],
});
} catch (error) {
results.push({
externalRef,
name,
key,
status: "error",
reason: error instanceof Error ? error.message : "Import failed",
secretId: null,
conflicts: [],
});
}
}
return {
providerConfigId: providerConfig.id,
provider: providerId,
importedCount: results.filter((result) => result.status === "imported").length,
skippedCount: results.filter((result) => result.status === "skipped").length,
errorCount: results.filter((result) => result.status === "error").length,
results,
};
},
getById,
getByName,
resolveSecretValue,