mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 00:53:38 +02:00
Implement AWS remote secret import backend
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
e38bf7f05d
commit
02a5f13044
14 files changed
+1074
No files matched your search
@@ -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.
|
||||
@@ -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,
|
||||
|
||||
@@ -253,6 +253,13 @@ export type {
|
||||
CompanySecretUsageBinding,
|
||||
CompanySecretVersion,
|
||||
SecretAccessEvent,
|
||||
RemoteSecretImportCandidate,
|
||||
RemoteSecretImportCandidateStatus,
|
||||
RemoteSecretImportConflict,
|
||||
RemoteSecretImportPreviewResult,
|
||||
RemoteSecretImportResult,
|
||||
RemoteSecretImportRowResult,
|
||||
RemoteSecretImportRowStatus,
|
||||
SecretAccessOutcome,
|
||||
SecretBindingTargetType,
|
||||
SecretManagedMode,
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user