mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 09:07:46 +02:00
Clarify AWS secret custody UX
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
f1c7a5c43c
commit
b84482de60
3 files changed
+299
-31
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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.");
|
||||
});
|
||||
});
|
||||
+200
-31
@@ -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<string, unknown> | 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 (
|
||||
<div className="p-6 text-sm text-muted-foreground">Select a company to manage secrets.</div>
|
||||
@@ -376,6 +496,7 @@ export function Secrets() {
|
||||
<thead className="bg-muted/40 text-xs uppercase tracking-wide text-muted-foreground">
|
||||
<tr>
|
||||
<th className="px-6 py-2 text-left font-medium">Name</th>
|
||||
<th className="px-2 py-2 text-left font-medium">Mode</th>
|
||||
<th className="px-2 py-2 text-left font-medium">Provider</th>
|
||||
<th className="px-2 py-2 text-left font-medium">Status</th>
|
||||
<th className="px-2 py-2 text-left font-medium">Version</th>
|
||||
@@ -399,6 +520,11 @@ export function Secrets() {
|
||||
<div className="font-medium text-foreground">{secret.name}</div>
|
||||
<div className="text-[11px] text-muted-foreground font-mono">{secret.key}</div>
|
||||
</td>
|
||||
<td className="px-2 py-2.5">
|
||||
<Badge variant="outline" className={cn("font-medium", modeBadgeTone(secret.managedMode))}>
|
||||
{modeLabel(secret.managedMode)}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-2 py-2.5 text-xs">{providerLabel(providers, secret.provider)}</td>
|
||||
<td className="px-2 py-2.5">
|
||||
<Badge variant="outline" className={cn("font-medium", statusBadgeTone(secret.status))}>
|
||||
@@ -419,7 +545,7 @@ export function Secrets() {
|
||||
{secret.externalRef ?? "—"}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">Managed</span>
|
||||
<span className="text-muted-foreground">Owned by Paperclip</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-2.5 text-right">
|
||||
@@ -452,6 +578,9 @@ export function Secrets() {
|
||||
<Badge variant="outline" className={cn("ml-2", statusBadgeTone(selectedSecret.status))}>
|
||||
{selectedSecret.status}
|
||||
</Badge>
|
||||
<Badge variant="outline" className={cn(modeBadgeTone(selectedSecret.managedMode))}>
|
||||
{modeLabel(selectedSecret.managedMode)}
|
||||
</Badge>
|
||||
</SheetTitle>
|
||||
<SheetDescription>
|
||||
{providerLabel(providers, selectedSecret.provider)} · v{selectedSecret.latestVersion} ·{" "}
|
||||
@@ -469,7 +598,8 @@ export function Secrets() {
|
||||
setRotateError(null);
|
||||
}}
|
||||
>
|
||||
<RefreshCw className="h-3.5 w-3.5 mr-1" /> Rotate
|
||||
<RefreshCw className="h-3.5 w-3.5 mr-1" />
|
||||
{selectedSecret.managedMode === "external_reference" ? "Update reference" : "Rotate"}
|
||||
</Button>
|
||||
{selectedSecret.status === "active" ? (
|
||||
<Button
|
||||
@@ -548,8 +678,8 @@ export function Secrets() {
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create secret</DialogTitle>
|
||||
<DialogDescription>
|
||||
Managed secrets store an encrypted value with the provider; external references link a value
|
||||
that already lives in the provider.
|
||||
Choose whether Paperclip should own future provider writes, or only resolve an existing
|
||||
provider reference at runtime.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Tabs value={createMode} onValueChange={(value) => setCreateMode(value as CreateMode)}>
|
||||
@@ -597,27 +727,59 @@ export function Secrets() {
|
||||
}
|
||||
>
|
||||
{providers.map((provider) => (
|
||||
<option key={provider.id} value={provider.id}>
|
||||
<option
|
||||
key={provider.id}
|
||||
value={provider.id}
|
||||
disabled={Boolean(
|
||||
getCreateProviderBlockReason(provider, createMode, providerHealthQuery.data ?? null),
|
||||
)}
|
||||
>
|
||||
{provider.label}
|
||||
{provider.requiresExternalRef ? " (external only)" : ""}
|
||||
{provider.configured === false
|
||||
? " (not configured)"
|
||||
: provider.requiresExternalRef
|
||||
? " (external only)"
|
||||
: ""}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{createProviderBlockReason ? (
|
||||
<p className="mt-1 flex items-center gap-1 text-[11px] text-destructive">
|
||||
<AlertCircle className="h-3 w-3" />
|
||||
{createProviderBlockReason}
|
||||
</p>
|
||||
) : createProviderHealthText ? (
|
||||
<p className="mt-1 text-[11px] text-muted-foreground">{createProviderHealthText}</p>
|
||||
) : null}
|
||||
</div>
|
||||
{createMode === "managed" ? (
|
||||
<div>
|
||||
<label className="text-xs font-medium" htmlFor="new-secret-value">Value</label>
|
||||
<Textarea
|
||||
id="new-secret-value"
|
||||
value={createForm.value}
|
||||
onChange={(event) =>
|
||||
setCreateForm((current) => ({ ...current, value: event.target.value }))
|
||||
}
|
||||
rows={3}
|
||||
className="font-mono text-xs"
|
||||
placeholder="Stored once, never re-displayed"
|
||||
/>
|
||||
</div>
|
||||
<>
|
||||
<div className="rounded-md border border-emerald-500/30 bg-emerald-500/5 p-2 text-[11px] text-emerald-700 dark:text-emerald-300">
|
||||
Paperclip-managed secrets are created in the selected provider and future rotations
|
||||
write a new provider version through Paperclip.
|
||||
{awsManagedPathPreview ? (
|
||||
<div className="mt-1">
|
||||
AWS managed path:{" "}
|
||||
<code className="break-all rounded bg-background/70 px-1 py-0.5">
|
||||
{awsManagedPathPreview}
|
||||
</code>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-medium" htmlFor="new-secret-value">Value</label>
|
||||
<Textarea
|
||||
id="new-secret-value"
|
||||
value={createForm.value}
|
||||
onChange={(event) =>
|
||||
setCreateForm((current) => ({ ...current, value: event.target.value }))
|
||||
}
|
||||
rows={3}
|
||||
className="font-mono text-xs"
|
||||
placeholder="Stored once, never re-displayed"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div>
|
||||
<label className="text-xs font-medium" htmlFor="new-secret-ref">External reference</label>
|
||||
@@ -631,7 +793,8 @@ export function Secrets() {
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
<p className="text-[11px] text-muted-foreground mt-1">
|
||||
The provider keeps custody of the value. Paperclip stores only the reference.
|
||||
Existing provider secrets are resolve-only in Paperclip. Rotate the value in the provider,
|
||||
then update this reference only if the path, ARN, or version changes.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -661,6 +824,7 @@ export function Secrets() {
|
||||
}}
|
||||
disabled={
|
||||
createMutation.isPending ||
|
||||
Boolean(createProviderBlockReason) ||
|
||||
!createForm.name.trim() ||
|
||||
(createMode === "managed" ? !createForm.value : !createForm.externalRef.trim())
|
||||
}
|
||||
@@ -675,9 +839,13 @@ export function Secrets() {
|
||||
<Dialog open={rotateOpen} onOpenChange={setRotateOpen}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Rotate secret</DialogTitle>
|
||||
<DialogTitle>
|
||||
{selectedSecret?.managedMode === "external_reference" ? "Update external reference" : "Rotate secret"}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Creates a new version. Consumers pinned to <code>latest</code> pick up the new value on the next run.
|
||||
{selectedSecret?.managedMode === "external_reference"
|
||||
? "Creates a new Paperclip metadata version that points at an existing provider secret. Paperclip does not write a new provider value."
|
||||
: "Creates a new provider-backed version. Consumers pinned to latest pick up the new value on the next run."}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{selectedSecret?.managedMode === "external_reference" ? (
|
||||
@@ -690,6 +858,9 @@ export function Secrets() {
|
||||
placeholder={selectedSecret.externalRef ?? "Updated reference"}
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
<p className="mt-1 text-[11px] text-muted-foreground">
|
||||
Rotate the actual value in the provider before changing this Paperclip reference.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
@@ -722,7 +893,7 @@ export function Secrets() {
|
||||
}
|
||||
>
|
||||
{rotateMutation.isPending ? <Loader2 className="h-3.5 w-3.5 animate-spin mr-1" /> : null}
|
||||
Rotate
|
||||
{selectedSecret?.managedMode === "external_reference" ? "Update reference" : "Rotate"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
@@ -826,16 +997,12 @@ function ProviderHealthBar({
|
||||
}
|
||||
|
||||
function SecretDetailsTab({ secret }: { secret: CompanySecret }) {
|
||||
const managedLabel: Record<SecretManagedMode, string> = {
|
||||
paperclip_managed: "Paperclip-managed value",
|
||||
external_reference: "External reference",
|
||||
};
|
||||
return (
|
||||
<dl className="grid grid-cols-2 gap-x-4 gap-y-3 text-xs">
|
||||
<DetailRow label="Description">
|
||||
<span>{secret.description ?? <span className="text-muted-foreground">—</span>}</span>
|
||||
</DetailRow>
|
||||
<DetailRow label="Managed mode">{managedLabel[secret.managedMode]}</DetailRow>
|
||||
<DetailRow label="Custody">{modeLabel(secret.managedMode)}</DetailRow>
|
||||
<DetailRow label="Provider">{secret.provider.replaceAll("_", " ")}</DetailRow>
|
||||
<DetailRow label="Latest version">v{secret.latestVersion}</DetailRow>
|
||||
<DetailRow label="Created">{formatRelative(secret.createdAt)}</DetailRow>
|
||||
@@ -844,14 +1011,16 @@ function SecretDetailsTab({ secret }: { secret: CompanySecret }) {
|
||||
<DetailRow label="Last resolved">{formatRelative(secret.lastResolvedAt)}</DetailRow>
|
||||
{secret.externalRef ? (
|
||||
<div className="col-span-2">
|
||||
<dt className="text-[11px] uppercase tracking-wide text-muted-foreground mb-1">External reference</dt>
|
||||
<dt className="text-[11px] uppercase tracking-wide text-muted-foreground mb-1">
|
||||
{secret.managedMode === "external_reference" ? "Linked provider reference" : "Provider-managed path"}
|
||||
</dt>
|
||||
<dd className="font-mono text-xs break-all flex items-center gap-1">
|
||||
<ExternalLink className="h-3 w-3" /> {secret.externalRef}
|
||||
</dd>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="col-span-2 rounded-md border border-amber-500/30 bg-amber-500/5 p-2 text-[11px] text-amber-700 dark:text-amber-300">
|
||||
Paperclip never re-displays stored values. To replace, rotate to create a new version.
|
||||
{modeDescription(secret.managedMode)} Paperclip never re-displays stored values.
|
||||
</div>
|
||||
</dl>
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user