Persist remote import descriptions

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-05-07 18:59:05 -05:00
1 parent ea62aa30e2
commit ae7a11328b
9 files changed
+176 -23

No files matched your search

+34 -4
View File
@@ -12,6 +12,12 @@ 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.
Remote import is an inventory and metadata workflow. Preview calls AWS
`ListSecrets` only and import stores a Paperclip external reference plus
fingerprint/version metadata. Neither route calls `GetSecretValue` or
`BatchGetSecretValue`, requests `SecretString`, requires KMS decrypt, logs raw
remote metadata, or copies secret plaintext into Paperclip.
## Preview Remote AWS Secrets
```
@@ -24,8 +30,10 @@ POST /api/companies/{companyId}/secrets/remote-import/preview
}
```
The backend calls AWS Secrets Manager `ListSecrets` only. It never calls
`GetSecretValue`, never requests `SecretString`, and never returns plaintext.
`query` is optional and is sent to AWS as an inventory filter. Treat it as
non-secret metadata because AWS may record list request parameters in
CloudTrail. `nextToken` is an opaque AWS cursor; pass it back unchanged.
`pageSize` is capped at 100.
Response:
@@ -41,7 +49,10 @@ Response:
"name": "prod/stripe",
"key": "prod-stripe",
"providerVersionRef": null,
"providerMetadata": { "name": "prod/stripe" },
"providerMetadata": {
"lastChangedDate": "2026-05-06T00:00:00.000Z",
"hasDescription": true
},
"status": "ready",
"importable": true,
"conflicts": []
@@ -56,6 +67,11 @@ Candidate `status` values:
- `duplicate`: an existing secret already has the exact provider `externalRef`.
- `conflict`: the suggested Paperclip `name` or `key` is already in use.
Conflict `type` values are `exact_reference`, `name`, `key`, and
`provider_guardrail`. AWS refs under Paperclip's own managed namespace are
blocked as external references so one company cannot import another company's
Paperclip-managed AWS secret through a broad runtime role.
## Import Remote AWS Secret References
```
@@ -67,8 +83,12 @@ POST /api/companies/{companyId}/secrets/remote-import
"externalRef": "arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/stripe",
"name": "Stripe production key",
"key": "stripe-production-key",
"description": "Stripe key used by production checkout",
"providerVersionRef": null,
"providerMetadata": { "name": "prod/stripe" }
"providerMetadata": {
"lastChangedDate": "2026-05-06T00:00:00.000Z",
"hasDescription": true
}
}
]
}
@@ -77,6 +97,11 @@ POST /api/companies/{companyId}/secrets/remote-import
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.
The `secrets` array accepts 1-100 rows, and the backend re-checks duplicates and
conflicts at submit time.
Each row may include an optional Paperclip `description` entered during review;
blank descriptions are stored as `null`. AWS provider descriptions are not
copied into this field.
```json
{
@@ -101,3 +126,8 @@ duplicates and name/key conflicts are skipped without failing the whole request.
Activity logs record aggregate counts and provider/vault ids only, not remote
secret names, ARNs, tags, or values.
Imported references may still fail during a future bound runtime resolution if
the Paperclip runtime role can list the AWS secret but lacks
`secretsmanager:GetSecretValue` or required KMS decrypt permission for that
specific secret.
@@ -124,13 +124,19 @@ describe("secret validators", () => {
externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/openai",
name: "OpenAI API key",
key: "OPENAI_API_KEY",
description: " Operator-entered Paperclip description ",
providerMetadata: { name: "prod/openai" },
},
],
}),
).toMatchObject({
providerConfigId: "11111111-1111-4111-8111-111111111111",
secrets: [expect.objectContaining({ key: "OPENAI_API_KEY" })],
secrets: [
expect.objectContaining({
key: "OPENAI_API_KEY",
description: "Operator-entered Paperclip description",
}),
],
});
});
+1
View File
@@ -266,6 +266,7 @@ 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(),
description: z.string().trim().max(500).optional().nullable(),
providerVersionRef: z.string().trim().min(1).max(512).optional().nullable(),
providerMetadata: z.record(z.unknown()).optional().nullable(),
});
@@ -355,6 +355,7 @@ describe("secret routes", () => {
externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/openai",
name: "OpenAI API key",
key: "openai-api-key",
description: "Operator-entered Paperclip description",
},
],
});
@@ -369,6 +370,7 @@ describe("secret routes", () => {
externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/openai",
name: "OpenAI API key",
key: "openai-api-key",
description: "Operator-entered Paperclip description",
},
],
},
@@ -518,6 +518,7 @@ describeEmbeddedPostgres("secretService", () => {
externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/openai",
name: "OpenAI API key",
key: "openai-api-key",
description: " Operator-entered production OpenAI key ",
providerMetadata: { arn: "arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/openai" },
},
],
@@ -547,6 +548,7 @@ describeEmbeddedPostgres("secretService", () => {
externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/openai",
createdByUserId: "user-1",
providerMetadata: null,
description: "Operator-entered production OpenAI key",
});
const versions = await db
+3 -1
View File
@@ -1197,6 +1197,7 @@ export function secretService(db: Db) {
externalRef: string;
name?: string | null;
key?: string | null;
description?: string | null;
providerVersionRef?: string | null;
providerMetadata?: Record<string, unknown> | null;
}>;
@@ -1218,6 +1219,7 @@ export function secretService(db: Db) {
const externalRef = selection.externalRef.trim();
const name = selection.name?.trim() || deriveSecretNameFromExternalRef(externalRef);
const key = normalizeSecretKey(selection.key?.trim() || name);
const description = selection.description?.trim() || null;
let prepared: PreparedSecretVersion | undefined;
const conflicts = remoteImportConflictsFor({
providerConfigId: providerConfig.id,
@@ -1326,7 +1328,7 @@ export function secretService(db: Db) {
externalRef: preparedSecret.externalRef,
providerMetadata: null,
latestVersion: 1,
description: null,
description,
lastRotatedAt: new Date(),
createdByAgentId: actor?.agentId ?? null,
createdByUserId: actor?.userId ?? null,
+1
View File
@@ -85,6 +85,7 @@ export interface RemoteImportSelectionInput {
externalRef: string;
name?: string | null;
key?: string | null;
description?: string | null;
providerVersionRef?: string | null;
providerMetadata?: Record<string, unknown> | null;
}
@@ -357,6 +357,113 @@ describe("ImportFromVaultDialog", () => {
});
});
it("submits the operator-entered review description", async () => {
const externalRef = "arn:aws:secretsmanager:us-east-1:1:secret:prod/openai-XYZ";
mockSecretsApi.remoteImportPreview.mockResolvedValueOnce(
makePreview([
makeCandidate({
externalRef,
remoteName: "prod/openai",
name: "OpenAI API key",
key: "openai-api-key",
providerMetadata: {
description: "Raw AWS description should not seed the review field",
},
}),
]),
);
mockSecretsApi.remoteImport.mockResolvedValueOnce({
providerConfigId: awsVault.id,
provider: "aws_secrets_manager",
importedCount: 1,
skippedCount: 0,
errorCount: 0,
results: [
{
externalRef,
name: "OpenAI API key",
key: "openai-api-key",
status: "imported",
reason: null,
secretId: "secret-openai",
conflicts: [],
},
],
});
const { queryClient } = makeWrapper();
const root = createRoot(container);
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<ImportFromVaultDialog
open
onOpenChange={vi.fn()}
companyId="company-1"
providerConfigs={[awsVault]}
existingSecrets={[]}
/>
</QueryClientProvider>,
);
});
await flush();
await flush();
const row = document.querySelector(
`[data-testid="vault-row-${externalRef}"]`,
) as HTMLElement | null;
await act(async () => {
row?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flush();
const continueBtn = Array.from(document.querySelectorAll("button")).find(
(btn) => btn.textContent?.includes("Continue"),
);
await act(async () => {
continueBtn!.click();
});
await flush();
const descriptionInput = document.querySelector(
`[data-testid="review-description-${externalRef}"]`,
) as HTMLInputElement | null;
expect(descriptionInput?.value).toBe("");
const valueSetter = Object.getOwnPropertyDescriptor(
window.HTMLInputElement.prototype,
"value",
)?.set;
await act(async () => {
valueSetter?.call(descriptionInput, "Operator-entered OpenAI key");
descriptionInput!.dispatchEvent(new Event("input", { bubbles: true }));
});
await flush();
const importBtn = Array.from(document.querySelectorAll("button")).find(
(btn) => btn.textContent?.startsWith("Import "),
) as HTMLButtonElement | undefined;
await act(async () => {
importBtn!.click();
});
await flush();
await flush();
expect(mockSecretsApi.remoteImport).toHaveBeenCalledWith("company-1", {
providerConfigId: awsVault.id,
secrets: [
expect.objectContaining({
externalRef,
description: "Operator-entered OpenAI key",
providerMetadata: null,
}),
],
});
await act(async () => {
root.unmount();
});
});
it("renders mixed import results (created/skipped/failed) and shows error reason", async () => {
mockSecretsApi.remoteImportPreview.mockResolvedValueOnce(
makePreview([
+19 -17
View File
@@ -234,19 +234,30 @@ function isThrottlingError(error: unknown): boolean {
}
function buildDraft(candidate: RemoteSecretImportCandidate): DraftSelection {
const meta = candidate.providerMetadata ?? {};
const description =
typeof (meta as Record<string, unknown>).description === "string"
? ((meta as Record<string, unknown>).description as string)
: "";
return {
candidate,
name: candidate.name,
key: candidate.key,
description,
description: "",
};
}
function safeImportProviderMetadata(
metadata: Record<string, unknown> | null | undefined,
): Record<string, unknown> | null {
if (!metadata) return null;
const safe: Record<string, unknown> = {};
for (const key of ["createdDate", "lastAccessedDate", "lastChangedDate", "deletedDate"]) {
const value = metadata[key];
if (typeof value === "string" || value === null) safe[key] = value;
}
for (const key of ["hasDescription", "hasKmsKey", "tagCount"]) {
const value = metadata[key];
if (typeof value === "boolean" || typeof value === "number") safe[key] = value;
}
return Object.keys(safe).length > 0 ? safe : null;
}
function validateDraftRow(
draft: DraftSelection,
existing: CompanySecret[],
@@ -610,8 +621,9 @@ export function ImportFromVaultDialog({
externalRef: draft.candidate.externalRef,
name: draft.name.trim(),
key: draft.key.trim(),
description: draft.description.trim() || null,
providerVersionRef: draft.candidate.providerVersionRef,
providerMetadata: draft.candidate.providerMetadata,
providerMetadata: safeImportProviderMetadata(draft.candidate.providerMetadata),
}));
importMutation.mutate({ providerConfigId: vaultId, secrets: items });
}
@@ -999,8 +1011,6 @@ function SelectStep(props: SelectStepProps) {
: typeof meta.lastChangedDate === "string"
? meta.lastChangedDate
: null;
const description =
typeof meta.description === "string" ? meta.description : null;
return (
<tr
key={candidate.externalRef}
@@ -1025,14 +1035,6 @@ function SelectStep(props: SelectStepProps) {
</td>
<td className="px-2 py-2.5">
<div className="text-sm font-medium leading-tight">{candidate.remoteName}</div>
{description && (
<div
className="truncate text-xs text-muted-foreground max-w-[20rem]"
title={description}
>
{description}
</div>
)}
</td>
<td className="px-2 py-2.5 text-xs">
<span