mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 09:07:46 +02:00
Harden AWS remote secret imports
This commit is contained in:
1 parent
02a5f13044
commit
ae538db38f
6 files changed
+413
-42
No files matched your search
@@ -198,7 +198,7 @@ export interface SecretAccessEvent {
|
||||
export type RemoteSecretImportCandidateStatus = "ready" | "duplicate" | "conflict";
|
||||
|
||||
export interface RemoteSecretImportConflict {
|
||||
type: "exact_reference" | "name" | "key";
|
||||
type: "exact_reference" | "name" | "key" | "provider_guardrail";
|
||||
message: string;
|
||||
existingSecretId?: string;
|
||||
}
|
||||
|
||||
@@ -269,7 +269,18 @@ describe("awsSecretsManagerProvider", () => {
|
||||
});
|
||||
|
||||
it("stores linked external references as metadata-only provider material", async () => {
|
||||
const provider = createAwsSecretsManagerProvider();
|
||||
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,
|
||||
},
|
||||
});
|
||||
|
||||
const prepared = await provider.linkExternalSecret({
|
||||
externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:shared/external",
|
||||
@@ -283,6 +294,29 @@ describe("awsSecretsManagerProvider", () => {
|
||||
expect(prepared.valueSha256).toBeTruthy();
|
||||
});
|
||||
|
||||
it("rejects linked external references under the Paperclip-managed namespace", async () => {
|
||||
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,
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
provider.linkExternalSecret({
|
||||
externalRef:
|
||||
"arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company-2/openai-api-key",
|
||||
providerVersionRef: "linked-version-7",
|
||||
}),
|
||||
).rejects.toThrow(/Paperclip-managed namespace/i);
|
||||
});
|
||||
|
||||
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({
|
||||
@@ -352,15 +386,16 @@ describe("awsSecretsManagerProvider", () => {
|
||||
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",
|
||||
hasDescription: true,
|
||||
tagCount: 1,
|
||||
}),
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(JSON.stringify(listed)).not.toContain("SecretString");
|
||||
expect(JSON.stringify(listed)).not.toContain("OpenAI API key");
|
||||
expect(JSON.stringify(listed)).not.toContain("team");
|
||||
});
|
||||
|
||||
it("resolves AWS secret values by provider version reference", async () => {
|
||||
|
||||
@@ -3,6 +3,7 @@ import request from "supertest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { secretRoutes } from "../routes/secrets.js";
|
||||
import { errorHandler } from "../middleware/error-handler.js";
|
||||
import { unprocessable } from "../errors.js";
|
||||
|
||||
const mockSecretService = vi.hoisted(() => ({
|
||||
listProviders: vi.fn(),
|
||||
@@ -14,7 +15,9 @@ const mockSecretService = vi.hoisted(() => ({
|
||||
disableProviderConfig: vi.fn(),
|
||||
setDefaultProviderConfig: vi.fn(),
|
||||
checkProviderConfigHealth: vi.fn(),
|
||||
getById: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
previewRemoteImport: vi.fn(),
|
||||
importRemoteSecrets: vi.fn(),
|
||||
}));
|
||||
@@ -357,4 +360,35 @@ describe("secret routes", () => {
|
||||
}));
|
||||
expect(JSON.stringify(mockLogActivity.mock.calls)).not.toContain("prod/openai");
|
||||
});
|
||||
|
||||
it("surfaces update-route externalRef retarget rejection without logging raw refs", async () => {
|
||||
mockSecretService.getById.mockResolvedValue({
|
||||
id: "22222222-2222-4222-8222-222222222222",
|
||||
companyId: "company-1",
|
||||
name: "OpenAI API key",
|
||||
key: "openai-api-key",
|
||||
provider: "aws_secrets_manager",
|
||||
managedMode: "external_reference",
|
||||
externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:shared/original",
|
||||
});
|
||||
mockSecretService.update.mockRejectedValue(
|
||||
unprocessable("External reference secrets cannot be retargeted through generic update"),
|
||||
);
|
||||
|
||||
const res = await request(createApp())
|
||||
.patch("/api/secrets/22222222-2222-4222-8222-222222222222")
|
||||
.send({
|
||||
externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:shared/repointed",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(422);
|
||||
expect(mockSecretService.update).toHaveBeenCalledWith(
|
||||
"22222222-2222-4222-8222-222222222222",
|
||||
expect.objectContaining({
|
||||
externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:shared/repointed",
|
||||
}),
|
||||
);
|
||||
expect(mockLogActivity).not.toHaveBeenCalled();
|
||||
expect(JSON.stringify(mockLogActivity.mock.calls)).not.toContain("shared/repointed");
|
||||
});
|
||||
});
|
||||
@@ -206,17 +206,23 @@ describeEmbeddedPostgres("secretService", () => {
|
||||
it("stores external references without requiring or persisting secret values", 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 secret = await svc.create(companyId, {
|
||||
name: `external-${randomUUID()}`,
|
||||
provider: "aws_secrets_manager",
|
||||
providerConfigId: awsVault.id,
|
||||
managedMode: "external_reference",
|
||||
externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/test",
|
||||
externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:shared/test",
|
||||
providerVersionRef: "version-1",
|
||||
});
|
||||
|
||||
expect(secret.managedMode).toBe("external_reference");
|
||||
expect(secret.externalRef).toBe("arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/test");
|
||||
expect(secret.externalRef).toBe("arn:aws:secretsmanager:us-east-1:123456789012:secret:shared/test");
|
||||
|
||||
const versions = await db
|
||||
.select()
|
||||
@@ -439,6 +445,7 @@ describeEmbeddedPostgres("secretService", () => {
|
||||
name: "prod/ready",
|
||||
key: "prod-ready",
|
||||
});
|
||||
expect(preview.candidates[2]?.providerMetadata).toBeNull();
|
||||
});
|
||||
|
||||
it("imports AWS remote references row-by-row without fetching plaintext", async () => {
|
||||
@@ -500,6 +507,7 @@ describeEmbeddedPostgres("secretService", () => {
|
||||
managedMode: "external_reference",
|
||||
externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/openai",
|
||||
createdByUserId: "user-1",
|
||||
providerMetadata: null,
|
||||
});
|
||||
|
||||
const versions = await db
|
||||
@@ -511,6 +519,112 @@ describeEmbeddedPostgres("secretService", () => {
|
||||
expect(JSON.stringify(versions[0])).not.toContain("sk-");
|
||||
});
|
||||
|
||||
it("rejects Paperclip-managed AWS namespace refs during preview and import commit", 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" },
|
||||
});
|
||||
|
||||
vi.spyOn(awsSecretsManagerProvider, "listRemoteSecrets").mockResolvedValue({
|
||||
secrets: [
|
||||
{
|
||||
externalRef:
|
||||
"arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company-b/openai",
|
||||
name: "paperclip/prod-use1/company-b/openai",
|
||||
providerVersionRef: null,
|
||||
metadata: {
|
||||
arn: "arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company-b/openai",
|
||||
description: "must not leak",
|
||||
tags: [{ Key: "paperclip:company-id", Value: "company-b" }],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const preview = await svc.previewRemoteImport(companyId, {
|
||||
providerConfigId: awsVault.id,
|
||||
});
|
||||
|
||||
expect(preview.candidates[0]).toMatchObject({
|
||||
status: "conflict",
|
||||
importable: false,
|
||||
conflicts: [expect.objectContaining({ type: "provider_guardrail" })],
|
||||
providerMetadata: null,
|
||||
});
|
||||
expect(JSON.stringify(preview)).not.toContain("must not leak");
|
||||
expect(JSON.stringify(preview)).not.toContain("paperclip:company-id");
|
||||
|
||||
const result = await svc.importRemoteSecrets(companyId, {
|
||||
providerConfigId: awsVault.id,
|
||||
secrets: [
|
||||
{
|
||||
externalRef:
|
||||
"arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company-b/openai",
|
||||
name: "Foreign managed secret",
|
||||
key: "foreign-managed-secret",
|
||||
providerMetadata: {
|
||||
description: "client-submitted metadata must not persist",
|
||||
tags: [{ Key: "paperclip:company-id", Value: "company-b" }],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
importedCount: 0,
|
||||
skippedCount: 0,
|
||||
errorCount: 1,
|
||||
results: [expect.objectContaining({ status: "error" })],
|
||||
});
|
||||
expect(result.results[0]?.reason).toMatch(/Paperclip-managed namespace/i);
|
||||
const imported = await db.select().from(companySecrets).where(eq(companySecrets.key, "foreign-managed-secret"));
|
||||
expect(imported).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("skips duplicate AWS remote imports for the same provider vault and canonical ref", 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 first = await svc.importRemoteSecrets(companyId, {
|
||||
providerConfigId: awsVault.id,
|
||||
secrets: [
|
||||
{
|
||||
externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/openai",
|
||||
name: "OpenAI API key",
|
||||
key: "openai-api-key",
|
||||
},
|
||||
],
|
||||
});
|
||||
const second = await svc.importRemoteSecrets(companyId, {
|
||||
providerConfigId: awsVault.id,
|
||||
secrets: [
|
||||
{
|
||||
externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/openai",
|
||||
name: "OpenAI API key duplicate",
|
||||
key: "openai-api-key-duplicate",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(first.importedCount).toBe(1);
|
||||
expect(second).toMatchObject({
|
||||
importedCount: 0,
|
||||
skippedCount: 1,
|
||||
errorCount: 0,
|
||||
results: [expect.objectContaining({ reason: "exact_reference_duplicate" })],
|
||||
});
|
||||
const imported = await db.select().from(companySecrets).where(eq(companySecrets.providerConfigId, awsVault.id));
|
||||
expect(imported).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("rejects remote import for disabled or cross-company provider vaults", async () => {
|
||||
const companyA = await seedCompany("A");
|
||||
const companyB = await seedCompany("B");
|
||||
@@ -558,6 +672,34 @@ describeEmbeddedPostgres("secretService", () => {
|
||||
).rejects.toThrow(/Managed secrets cannot override externalRef/i);
|
||||
});
|
||||
|
||||
it("rejects generic update retargeting for external reference secrets", 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 secret = await svc.create(companyId, {
|
||||
name: `external-${randomUUID()}`,
|
||||
provider: "aws_secrets_manager",
|
||||
providerConfigId: awsVault.id,
|
||||
managedMode: "external_reference",
|
||||
externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:shared/original",
|
||||
});
|
||||
|
||||
await expect(
|
||||
svc.update(secret.id, {
|
||||
externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:shared/repointed",
|
||||
}),
|
||||
).rejects.toThrow(/cannot be retargeted/i);
|
||||
|
||||
const persisted = await svc.getById(secret.id);
|
||||
expect(persisted?.externalRef).toBe(
|
||||
"arn:aws:secretsmanager:us-east-1:123456789012:secret:shared/original",
|
||||
);
|
||||
});
|
||||
|
||||
it("passes managed AWS secret context into provider delete during removal", async () => {
|
||||
const companyId = await seedCompany();
|
||||
const svc = secretService(db);
|
||||
|
||||
@@ -337,6 +337,32 @@ function isManagedSecretRefForContext(
|
||||
return new RegExp(`^${escapeRegExp(expectedName)}(?:-[A-Za-z0-9]{6})?$`).test(actualName);
|
||||
}
|
||||
|
||||
function isManagedSecretNamespaceRef(
|
||||
config: AwsSecretsManagerConfig,
|
||||
externalRef: string | null | undefined,
|
||||
) {
|
||||
if (!externalRef?.trim()) return false;
|
||||
const namespacePrefix = [
|
||||
sanitizePathSegment(config.prefix),
|
||||
sanitizePathSegment(config.deploymentId),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("/");
|
||||
if (!namespacePrefix) return false;
|
||||
const actualName = extractAwsSecretName(externalRef);
|
||||
return actualName === namespacePrefix || actualName.startsWith(`${namespacePrefix}/`);
|
||||
}
|
||||
|
||||
function assertNotManagedNamespaceExternalRef(
|
||||
config: AwsSecretsManagerConfig,
|
||||
externalRef: string,
|
||||
) {
|
||||
if (!isManagedSecretNamespaceRef(config, externalRef)) return;
|
||||
throw unprocessable(
|
||||
"AWS Paperclip-managed namespace secrets cannot be imported as external references",
|
||||
);
|
||||
}
|
||||
|
||||
function resolveManagedSecretRef(input: {
|
||||
config: AwsSecretsManagerConfig;
|
||||
context: ManagedSecretNamespaceContext | undefined;
|
||||
@@ -414,15 +440,13 @@ function serializeAwsDate(value: string | number | Date | undefined): string | n
|
||||
|
||||
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 ?? [],
|
||||
hasDescription: Boolean(entry.Description),
|
||||
hasKmsKey: Boolean(entry.KmsKeyId),
|
||||
tagCount: Array.isArray(entry.Tags) ? entry.Tags.length : 0,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -740,6 +764,8 @@ export function createAwsSecretsManagerProvider(
|
||||
}
|
||||
},
|
||||
async linkExternalSecret(input) {
|
||||
const config = resolveConfig(input.providerConfig);
|
||||
assertNotManagedNamespaceExternalRef(config, input.externalRef);
|
||||
return createExternalReferenceMaterial(input.externalRef, input.providerVersionRef ?? null);
|
||||
},
|
||||
async listRemoteSecrets(input): Promise<RemoteSecretListResult> {
|
||||
|
||||
+164
-30
@@ -42,7 +42,11 @@ import {
|
||||
getSecretProvider,
|
||||
listSecretProviders,
|
||||
} from "../secrets/provider-registry.js";
|
||||
import type { SecretProviderHealthCheck, SecretProviderVaultRuntimeConfig } from "../secrets/types.js";
|
||||
import type {
|
||||
PreparedSecretVersion,
|
||||
SecretProviderHealthCheck,
|
||||
SecretProviderVaultRuntimeConfig,
|
||||
} from "../secrets/types.js";
|
||||
|
||||
const ENV_KEY_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
||||
const SENSITIVE_ENV_KEY_RE =
|
||||
@@ -689,29 +693,61 @@ export function secretService(db: Db) {
|
||||
name: companySecrets.name,
|
||||
key: companySecrets.key,
|
||||
provider: companySecrets.provider,
|
||||
providerConfigId: companySecrets.providerConfigId,
|
||||
externalRef: companySecrets.externalRef,
|
||||
})
|
||||
.from(companySecrets)
|
||||
.where(eq(companySecrets.companyId, companyId));
|
||||
return {
|
||||
byExternalRef: new Map(
|
||||
byProviderConfigExternalRef: new Map(
|
||||
existingSecrets
|
||||
.filter((secret) => secret.provider === provider && typeof secret.externalRef === "string" && secret.externalRef.trim())
|
||||
.map((secret) => [secret.externalRef!.trim(), secret]),
|
||||
.filter((secret) =>
|
||||
secret.provider === provider &&
|
||||
typeof secret.externalRef === "string" &&
|
||||
secret.externalRef.trim()
|
||||
)
|
||||
.map((secret) => [
|
||||
remoteImportExternalRefKey(secret.providerConfigId, secret.externalRef!),
|
||||
secret,
|
||||
]),
|
||||
),
|
||||
byName: new Map(existingSecrets.map((secret) => [secret.name, secret])),
|
||||
byKey: new Map(existingSecrets.map((secret) => [secret.key, secret])),
|
||||
};
|
||||
}
|
||||
|
||||
function remoteImportExternalRefKey(providerConfigId: string | null | undefined, externalRef: string) {
|
||||
return `${providerConfigId ?? "default"}\0${externalRef.trim()}`;
|
||||
}
|
||||
|
||||
function sanitizeRemoteProviderMetadata(
|
||||
provider: SecretProvider,
|
||||
metadata: Record<string, unknown> | null | undefined,
|
||||
): Record<string, unknown> | null {
|
||||
if (!metadata || provider !== "aws_secrets_manager") 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 remoteImportConflictsFor(input: {
|
||||
providerConfigId: string | null;
|
||||
externalRef: string;
|
||||
name: string;
|
||||
key: string;
|
||||
maps: Awaited<ReturnType<typeof buildRemoteImportConflictMaps>>;
|
||||
}): RemoteSecretImportConflict[] {
|
||||
const conflicts: RemoteSecretImportConflict[] = [];
|
||||
const duplicate = input.maps.byExternalRef.get(input.externalRef);
|
||||
const duplicate = input.maps.byProviderConfigExternalRef.get(
|
||||
remoteImportExternalRefKey(input.providerConfigId, input.externalRef),
|
||||
);
|
||||
if (duplicate) {
|
||||
conflicts.push({
|
||||
type: "exact_reference",
|
||||
@@ -1009,26 +1045,54 @@ export function secretService(db: Db) {
|
||||
pageSize: input.pageSize,
|
||||
});
|
||||
const maps = await buildRemoteImportConflictMaps(companyId, providerId);
|
||||
const candidates: RemoteSecretImportCandidate[] = listed.secrets.map((remote) => {
|
||||
const candidates: RemoteSecretImportCandidate[] = [];
|
||||
for (const remote of listed.secrets) {
|
||||
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 });
|
||||
let canonicalExternalRef = externalRef;
|
||||
const conflicts: RemoteSecretImportConflict[] = [];
|
||||
try {
|
||||
const prepared = await provider.linkExternalSecret({
|
||||
externalRef,
|
||||
providerVersionRef: remote.providerVersionRef ?? null,
|
||||
providerConfig: runtimeConfig,
|
||||
context: {
|
||||
companyId,
|
||||
secretKey: key || "remote-import-preview",
|
||||
secretName: name,
|
||||
version: 1,
|
||||
},
|
||||
});
|
||||
canonicalExternalRef = prepared.externalRef ?? externalRef;
|
||||
} catch (error) {
|
||||
conflicts.push({
|
||||
type: "provider_guardrail",
|
||||
message: error instanceof Error ? error.message : "Provider rejected this external reference",
|
||||
});
|
||||
}
|
||||
conflicts.push(...remoteImportConflictsFor({
|
||||
providerConfigId: providerConfig.id,
|
||||
externalRef: canonicalExternalRef,
|
||||
name,
|
||||
key,
|
||||
maps,
|
||||
}));
|
||||
const hasDuplicate = conflicts.some((conflict) => conflict.type === "exact_reference");
|
||||
const hasConflict = conflicts.length > 0;
|
||||
return {
|
||||
candidates.push({
|
||||
externalRef,
|
||||
remoteName,
|
||||
name,
|
||||
key,
|
||||
providerVersionRef: remote.providerVersionRef ?? null,
|
||||
providerMetadata: remote.metadata ?? null,
|
||||
providerMetadata: sanitizeRemoteProviderMetadata(providerId, remote.metadata),
|
||||
status: hasDuplicate ? "duplicate" : hasConflict ? "conflict" : "ready",
|
||||
importable: !hasConflict,
|
||||
conflicts,
|
||||
};
|
||||
});
|
||||
});
|
||||
}
|
||||
return {
|
||||
providerConfigId: providerConfig.id,
|
||||
provider: providerId,
|
||||
@@ -1066,7 +1130,14 @@ 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 conflicts = remoteImportConflictsFor({ externalRef, name, key, maps });
|
||||
let prepared: PreparedSecretVersion | undefined;
|
||||
const conflicts = remoteImportConflictsFor({
|
||||
providerConfigId: providerConfig.id,
|
||||
externalRef,
|
||||
name,
|
||||
key,
|
||||
maps,
|
||||
});
|
||||
if (!key) {
|
||||
results.push({
|
||||
externalRef,
|
||||
@@ -1079,6 +1150,42 @@ export function secretService(db: Db) {
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (conflicts.length === 0) {
|
||||
try {
|
||||
prepared = await provider.linkExternalSecret({
|
||||
externalRef,
|
||||
providerVersionRef: selection.providerVersionRef ?? null,
|
||||
providerConfig: runtimeConfig,
|
||||
context: {
|
||||
companyId,
|
||||
secretKey: key,
|
||||
secretName: name,
|
||||
version: 1,
|
||||
},
|
||||
});
|
||||
const canonicalDuplicate = maps.byProviderConfigExternalRef.get(
|
||||
remoteImportExternalRefKey(providerConfig.id, prepared.externalRef ?? externalRef),
|
||||
);
|
||||
if (canonicalDuplicate) {
|
||||
conflicts.push({
|
||||
type: "exact_reference",
|
||||
existingSecretId: canonicalDuplicate.id,
|
||||
message: "An existing secret already links this exact provider reference.",
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
results.push({
|
||||
externalRef,
|
||||
name,
|
||||
key,
|
||||
status: "error",
|
||||
reason: error instanceof Error ? error.message : "Provider rejected this external reference",
|
||||
secretId: null,
|
||||
conflicts: [],
|
||||
});
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (conflicts.length > 0) {
|
||||
results.push({
|
||||
externalRef,
|
||||
@@ -1095,17 +1202,23 @@ export function secretService(db: Db) {
|
||||
}
|
||||
|
||||
try {
|
||||
const prepared = await provider.linkExternalSecret({
|
||||
externalRef,
|
||||
providerVersionRef: selection.providerVersionRef ?? null,
|
||||
providerConfig: runtimeConfig,
|
||||
context: {
|
||||
companyId,
|
||||
secretKey: key,
|
||||
secretName: name,
|
||||
version: 1,
|
||||
},
|
||||
});
|
||||
if (!prepared) {
|
||||
prepared = await provider.linkExternalSecret({
|
||||
externalRef,
|
||||
providerVersionRef: selection.providerVersionRef ?? null,
|
||||
providerConfig: runtimeConfig,
|
||||
context: {
|
||||
companyId,
|
||||
secretKey: key,
|
||||
secretName: name,
|
||||
version: 1,
|
||||
},
|
||||
});
|
||||
}
|
||||
if (!prepared) {
|
||||
throw unprocessable("Provider rejected this external reference");
|
||||
}
|
||||
const preparedSecret = prepared;
|
||||
const secret = await db.transaction(async (tx) => {
|
||||
const inserted = await tx
|
||||
.insert(companySecrets)
|
||||
@@ -1117,8 +1230,8 @@ export function secretService(db: Db) {
|
||||
providerConfigId: providerConfig.id,
|
||||
status: "active",
|
||||
managedMode: "external_reference",
|
||||
externalRef: prepared.externalRef,
|
||||
providerMetadata: selection.providerMetadata ?? null,
|
||||
externalRef: preparedSecret.externalRef,
|
||||
providerMetadata: null,
|
||||
latestVersion: 1,
|
||||
description: null,
|
||||
lastRotatedAt: new Date(),
|
||||
@@ -1130,17 +1243,20 @@ export function secretService(db: Db) {
|
||||
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,
|
||||
material: preparedSecret.material,
|
||||
valueSha256: preparedSecret.valueSha256,
|
||||
fingerprintSha256: preparedSecret.fingerprintSha256 ?? preparedSecret.valueSha256,
|
||||
providerVersionRef: preparedSecret.providerVersionRef ?? null,
|
||||
status: "current",
|
||||
createdByAgentId: actor?.agentId ?? null,
|
||||
createdByUserId: actor?.userId ?? null,
|
||||
});
|
||||
return inserted;
|
||||
});
|
||||
maps.byExternalRef.set(externalRef, secret);
|
||||
maps.byProviderConfigExternalRef.set(
|
||||
remoteImportExternalRefKey(providerConfig.id, preparedSecret.externalRef ?? externalRef),
|
||||
secret,
|
||||
);
|
||||
maps.byName.set(name, secret);
|
||||
maps.byKey.set(key, secret);
|
||||
results.push({
|
||||
@@ -1413,6 +1529,24 @@ export function secretService(db: Db) {
|
||||
if (secret.managedMode !== "external_reference" && patch.externalRef !== undefined) {
|
||||
throw unprocessable("Managed secrets cannot override externalRef");
|
||||
}
|
||||
if (
|
||||
secret.managedMode === "external_reference" &&
|
||||
patch.externalRef !== undefined &&
|
||||
patch.externalRef !== secret.externalRef
|
||||
) {
|
||||
throw unprocessable(
|
||||
"External reference secrets cannot be retargeted through generic update",
|
||||
);
|
||||
}
|
||||
if (
|
||||
secret.managedMode === "external_reference" &&
|
||||
patch.providerConfigId !== undefined &&
|
||||
patch.providerConfigId !== secret.providerConfigId
|
||||
) {
|
||||
throw unprocessable(
|
||||
"External reference secrets cannot change provider vault through generic update",
|
||||
);
|
||||
}
|
||||
if (patch.providerConfigId !== undefined) {
|
||||
await assertProviderConfigForSecret(
|
||||
secret.companyId,
|
||||
|
||||
Reference in new issue
Block a user