diff --git a/doc/SECRETS-AWS-PROVIDER.md b/doc/SECRETS-AWS-PROVIDER.md index 2875093ff8e..8e08599a6d8 100644 --- a/doc/SECRETS-AWS-PROVIDER.md +++ b/doc/SECRETS-AWS-PROVIDER.md @@ -70,6 +70,33 @@ Operational expectation: - Paperclip-managed secrets may be deleted only by Paperclip or an operator with equivalent break-glass access. - External references may resolve through Paperclip runtime, but Paperclip should not delete the external secret resource. +## Existing AWS Secrets + +V1 keeps existing AWS Secrets Manager entries as **linked external references**, not adopted +Paperclip-managed resources. + +Use the Paperclip-managed flow when Paperclip should create and rotate the value. The AWS +secret name is derived from deployment and company scope: + +```text +paperclip/{deploymentId}/{companyId}/{secretKey} +``` + +Use the external-reference flow when the secret already exists at an operator-owned path such +as: + +```text +/paperclip-bench/anthropic_api_key +``` + +In that mode Paperclip stores only the path or ARN, resolves it at runtime, and records +redacted access events. Operators rotate the actual value in AWS. Update the Paperclip +reference only when the AWS path, ARN, or pinned provider version changes. + +Paperclip does not currently offer an "adopt existing AWS secret" flow that takes over future +`PutSecretValue` writes for an arbitrary existing secret. Adding that later requires explicit +confirmation UX, scope validation, expected Paperclip tags, and security/cloud-ops review. + ## Data Custody - Paperclip stores `externalRef`, `providerVersionRef`, provider id, fingerprint hash, status, and binding metadata. diff --git a/ui/src/pages/Secrets.test.ts b/ui/src/pages/Secrets.test.ts new file mode 100644 index 00000000000..da2b94d6b98 --- /dev/null +++ b/ui/src/pages/Secrets.test.ts @@ -0,0 +1,72 @@ +// @vitest-environment jsdom + +import { describe, expect, it } from "vitest"; +import type { SecretProviderDescriptor } from "@paperclipai/shared"; +import { + getAwsManagedPathPreview, + getCreateProviderBlockReason, +} from "./Secrets"; +import type { SecretProviderHealthResponse } from "../api/secrets"; + +const awsProvider: SecretProviderDescriptor = { + id: "aws_secrets_manager", + label: "AWS Secrets Manager", + requiresExternalRef: false, + supportsManagedValues: true, + supportsExternalReferences: true, + configured: true, +}; + +describe("Secrets page provider helpers", () => { + it("previews the derived AWS managed path from provider health details", () => { + const health: SecretProviderHealthResponse = { + providers: [ + { + provider: "aws_secrets_manager", + status: "ok", + message: "AWS Secrets Manager provider is configured", + details: { + prefix: "paperclip", + deploymentId: "prod-us-1", + }, + }, + ], + }; + + expect( + getAwsManagedPathPreview({ + provider: awsProvider, + health, + companyId: "company-123", + secretKeySource: "Anthropic API Key", + }), + ).toBe("paperclip/prod-us-1/company-123/anthropic-api-key"); + }); + + it("blocks unconfigured providers before create submission", () => { + expect( + getCreateProviderBlockReason( + { ...awsProvider, configured: false }, + "managed", + null, + ), + ).toBe("AWS Secrets Manager is not configured in this deployment."); + }); + + it("blocks provider modes the backend does not support", () => { + expect( + getCreateProviderBlockReason( + { + id: "local_encrypted", + label: "Local encrypted (default)", + requiresExternalRef: false, + supportsManagedValues: true, + supportsExternalReferences: false, + configured: true, + }, + "external", + null, + ), + ).toBe("Local encrypted (default) does not support linked external references."); + }); +}); diff --git a/ui/src/pages/Secrets.tsx b/ui/src/pages/Secrets.tsx index 08e76203f7c..e6f9c12612a 100644 --- a/ui/src/pages/Secrets.tsx +++ b/ui/src/pages/Secrets.tsx @@ -93,9 +93,93 @@ function providerLabel(providers: SecretProviderDescriptor[] | undefined, id: Se return providers?.find((p) => p.id === id)?.label ?? id.replaceAll("_", " "); } +function normalizeSecretKeyForPreview(input: string) { + return input + .trim() + .toLowerCase() + .replace(/[^a-z0-9_.-]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 120); +} + +function modeBadgeTone(managedMode: SecretManagedMode) { + return managedMode === "paperclip_managed" + ? "border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300" + : "border-sky-500/30 bg-sky-500/10 text-sky-700 dark:text-sky-300"; +} + +function modeLabel(managedMode: SecretManagedMode) { + return managedMode === "paperclip_managed" ? "Paperclip-managed" : "Linked external"; +} + +function modeDescription(managedMode: SecretManagedMode) { + return managedMode === "paperclip_managed" + ? "Paperclip owns create and rotation writes for this provider secret." + : "Paperclip resolves this provider reference but does not rotate the provider value."; +} + +function healthEntryForProvider( + health: SecretProviderHealthResponse | null, + providerId: SecretProvider, +) { + return health?.providers.find((entry) => entry.provider === providerId) ?? null; +} + +export function getCreateProviderBlockReason( + provider: SecretProviderDescriptor | null | undefined, + mode: CreateMode, + health: SecretProviderHealthResponse | null, +) { + if (!provider) return "Select a provider."; + if (mode === "managed" && provider.supportsManagedValues === false) { + return `${provider.label} does not support Paperclip-managed secret values.`; + } + if (mode === "external" && provider.supportsExternalReferences === false) { + return `${provider.label} does not support linked external references.`; + } + if (provider.configured === false) { + return `${provider.label} is not configured in this deployment.`; + } + const healthEntry = healthEntryForProvider(health, provider.id); + if (healthEntry?.status === "error") { + return `${provider.label} health check failed: ${healthEntry.message}`; + } + return null; +} + +function providerHealthText( + provider: SecretProviderDescriptor | null | undefined, + health: SecretProviderHealthResponse | null, +) { + if (!provider) return null; + const entry = healthEntryForProvider(health, provider.id); + if (!entry) return null; + const warnings = entry.warnings?.join(" "); + return [entry.message, warnings].filter(Boolean).join(" "); +} + +function detailString(details: Record | undefined, key: string) { + const value = details?.[key]; + return typeof value === "string" && value.trim() ? value.trim() : null; +} + +export function getAwsManagedPathPreview(input: { + provider: SecretProviderDescriptor | null | undefined; + health: SecretProviderHealthResponse | null; + companyId: string; + secretKeySource: string; +}) { + if (input.provider?.id !== "aws_secrets_manager") return null; + const healthEntry = healthEntryForProvider(input.health, "aws_secrets_manager"); + const prefix = detailString(healthEntry?.details, "prefix") ?? "paperclip"; + const deploymentId = detailString(healthEntry?.details, "deploymentId") ?? "{deploymentId}"; + const secretKey = normalizeSecretKeyForPreview(input.secretKeySource) || "{secretKey}"; + return `${prefix}/${deploymentId}/${input.companyId}/${secretKey}`; +} + export function Secrets() { const queryClient = useQueryClient(); - const { selectedCompany, selectedCompanyId } = useCompany(); + const { selectedCompanyId } = useCompany(); const { setBreadcrumbs } = useBreadcrumbs(); const { pushToast } = useToastActions(); const [search, setSearch] = useState(""); @@ -156,6 +240,25 @@ export function Secrets() { () => secrets.find((secret) => secret.id === selectedSecretId) ?? null, [secrets, selectedSecretId], ); + const selectedCreateProvider = useMemo( + () => providers.find((provider) => provider.id === createForm.provider) ?? null, + [providers, createForm.provider], + ); + const createProviderBlockReason = getCreateProviderBlockReason( + selectedCreateProvider, + createMode, + providerHealthQuery.data ?? null, + ); + const createProviderHealthText = providerHealthText( + selectedCreateProvider, + providerHealthQuery.data ?? null, + ); + const awsManagedPathPreview = getAwsManagedPathPreview({ + provider: selectedCreateProvider, + health: providerHealthQuery.data ?? null, + companyId: selectedCompanyId ?? "{companyId}", + secretKeySource: createForm.key.trim() || createForm.name, + }); const filtered = useMemo(() => { const needle = search.trim().toLowerCase(); @@ -289,6 +392,23 @@ export function Secrets() { }, }); + useEffect(() => { + if (!createOpen || providers.length === 0) return; + const currentBlockReason = getCreateProviderBlockReason( + providers.find((provider) => provider.id === createForm.provider) ?? null, + createMode, + providerHealthQuery.data ?? null, + ); + if (!currentBlockReason) return; + const replacement = providers.find( + (provider) => + !getCreateProviderBlockReason(provider, createMode, providerHealthQuery.data ?? null), + ); + if (replacement && replacement.id !== createForm.provider) { + setCreateForm((current) => ({ ...current, provider: replacement.id })); + } + }, [createForm.provider, createMode, createOpen, providerHealthQuery.data, providers]); + if (!selectedCompanyId) { return (
Select a company to manage secrets.
@@ -376,6 +496,7 @@ export function Secrets() { Name + Mode Provider Status Version @@ -399,6 +520,11 @@ export function Secrets() {
{secret.name}
{secret.key}
+ + + {modeLabel(secret.managedMode)} + + {providerLabel(providers, secret.provider)} @@ -419,7 +545,7 @@ export function Secrets() { {secret.externalRef ?? "—"} ) : ( - Managed + Owned by Paperclip )} @@ -452,6 +578,9 @@ export function Secrets() { {selectedSecret.status} + + {modeLabel(selectedSecret.managedMode)} + {providerLabel(providers, selectedSecret.provider)} · v{selectedSecret.latestVersion} ·{" "} @@ -469,7 +598,8 @@ export function Secrets() { setRotateError(null); }} > - Rotate + + {selectedSecret.managedMode === "external_reference" ? "Update reference" : "Rotate"} {selectedSecret.status === "active" ? (