diff --git a/packages/shared/src/types/secrets.ts b/packages/shared/src/types/secrets.ts index 3d754476da5..47e16022b76 100644 --- a/packages/shared/src/types/secrets.ts +++ b/packages/shared/src/types/secrets.ts @@ -128,6 +128,7 @@ export interface SecretProviderDescriptor { requiresExternalRef: boolean; supportsManagedValues?: boolean; supportsExternalReferences?: boolean; + supportsExternalValueWrites?: boolean; configured?: boolean; } diff --git a/server/src/__tests__/aws-secrets-manager-provider.test.ts b/server/src/__tests__/aws-secrets-manager-provider.test.ts index 1b0e60d05a2..c2fc4cb6e17 100644 --- a/server/src/__tests__/aws-secrets-manager-provider.test.ts +++ b/server/src/__tests__/aws-secrets-manager-provider.test.ts @@ -386,6 +386,153 @@ describe("awsSecretsManagerProvider", () => { ).rejects.toThrow(/Paperclip-managed namespace/i); }); + it("writes new values through to externally referenced AWS secrets as AWSCURRENT", async () => { + const calls: Array<{ op: string; input: Record }> = []; + 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(input) { + calls.push({ op: "putSecretValue", input }); + return { + ARN: "arn:aws:secretsmanager:us-east-1:123456789012:secret:shared/external", + VersionId: "aws-version-9", + }; + }, + async getSecretValue(input) { + calls.push({ op: "getSecretValue", input }); + return { VersionId: "aws-version-8", SecretString: "previous-value" }; + }, + async deleteSecret() { + throw new Error("not used"); + }, + }, + }); + + const prepared = await provider.updateExternalSecretValue!({ + externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:shared/external", + value: "written-through-value", + context: { + companyId: "company-1", + secretKey: "neon-admin-api-key", + secretName: "paperclip-cloud/prod/provider/neon/admin-api-key", + version: 2, + }, + }); + + // No VersionStages: the write must become AWSCURRENT for all consumers. + expect(calls).toEqual([ + { + op: "getSecretValue", + input: { + SecretId: "arn:aws:secretsmanager:us-east-1:123456789012:secret:shared/external", + VersionStage: "AWSCURRENT", + }, + }, + { + op: "putSecretValue", + input: { + SecretId: "arn:aws:secretsmanager:us-east-1:123456789012:secret:shared/external", + SecretString: "written-through-value", + }, + }, + ]); + expect(JSON.stringify(prepared.material)).not.toContain("written-through-value"); + expect(prepared.externalRef).toBe( + "arn:aws:secretsmanager:us-east-1:123456789012:secret:shared/external", + ); + // Resolution keeps tracking AWSCURRENT rather than pinning the written version. + expect(prepared.providerVersionRef).toBeNull(); + expect(prepared.material.versionId).toBeNull(); + expect(prepared.material.lastWrittenVersionId).toBe("aws-version-9"); + expect(prepared.material.previousCurrentVersionId).toBe("aws-version-8"); + expect(prepared.material.source).toBe("external_reference"); + }); + + it("rejects external value writes 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: null, + environmentTag: "production", + providerOwnerTag: "paperclip", + deleteRecoveryWindowDays: 30, + }, + }); + + await expect( + provider.updateExternalSecretValue!({ + externalRef: + "arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company-2/openai-api-key", + value: "new-value", + }), + ).rejects.toThrow(/Paperclip-managed namespace/i); + }); + + it("restores the previous AWSCURRENT version when an external value write is rolled back", async () => { + const calls: Array<{ op: string; input: Record }> = []; + const provider = createAwsSecretsManagerProvider({ + config: { + region: "us-east-1", + endpoint: "https://secretsmanager.us-east-1.amazonaws.com", + deploymentId: "prod-use1", + prefix: "paperclip", + kmsKeyId: null, + 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("not used"); }, + async deleteSecret() { throw new Error("not used"); }, + async updateSecretVersionStage(input) { + calls.push({ op: "updateSecretVersionStage", input }); + }, + }, + }); + + await provider.deleteOrArchive({ + material: { + scheme: "aws_secrets_manager_v1", + secretId: "arn:aws:secretsmanager:us-east-1:123456789012:secret:shared/external", + versionId: null, + source: "external_reference", + lastWrittenVersionId: "aws-version-9", + previousCurrentVersionId: "aws-version-8", + }, + externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:shared/external", + mode: "archive", + }); + + expect(calls).toEqual([ + { + op: "updateSecretVersionStage", + input: { + SecretId: "arn:aws:secretsmanager:us-east-1:123456789012:secret:shared/external", + VersionStage: "AWSCURRENT", + MoveToVersionId: "aws-version-8", + RemoveFromVersionId: "aws-version-9", + }, + }, + ]); + }); + it("lists remote AWS secrets with metadata only and never resolves plaintext", async () => { const calls: Array<{ op: string; input: Record }> = []; const provider = createAwsSecretsManagerProvider({ diff --git a/server/src/__tests__/secrets-service.test.ts b/server/src/__tests__/secrets-service.test.ts index b025ca3779f..6279a95e18d 100644 --- a/server/src/__tests__/secrets-service.test.ts +++ b/server/src/__tests__/secrets-service.test.ts @@ -2767,6 +2767,156 @@ describeEmbeddedPostgres("secretService", () => { expect(thrown instanceof Error ? thrown.message : String(thrown)).not.toContain("arn:aws"); }); + it("writes external reference rotations through the provider when a value is given", 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 externalRef = "arn:aws:secretsmanager:us-east-1:123456789012:secret:shared/neon-admin"; + const secret = await svc.create(companyId, { + name: `external-${randomUUID()}`, + provider: "aws_secrets_manager", + providerConfigId: awsVault.id, + managedMode: "external_reference", + externalRef, + }); + + const writeSpy = vi + .spyOn(awsSecretsManagerProvider, "updateExternalSecretValue") + .mockResolvedValueOnce({ + material: { + scheme: "aws_secrets_manager_v1", + secretId: externalRef, + versionId: null, + source: "external_reference", + lastWrittenVersionId: "aws-version-2", + }, + valueSha256: "a".repeat(64), + fingerprintSha256: "a".repeat(64), + externalRef, + providerVersionRef: null, + }); + const linkSpy = vi.spyOn(awsSecretsManagerProvider, "linkExternalSecret"); + + const rotated = await svc.rotate(secret.id, { value: "new-admin-key" }, { userId: "user-1" }); + + expect(writeSpy).toHaveBeenCalledTimes(1); + expect(writeSpy.mock.calls[0]?.[0]).toMatchObject({ + externalRef, + value: "new-admin-key", + }); + expect(linkSpy).not.toHaveBeenCalled(); + expect(rotated.latestVersion).toBe(2); + expect(rotated.externalRef).toBe(externalRef); + + const versions = await db + .select() + .from(companySecretVersions) + .where(eq(companySecretVersions.secretId, secret.id)); + const current = versions.find((row) => row.status === "current"); + expect(current?.version).toBe(2); + expect(current?.providerVersionRef).toBeNull(); + expect(JSON.stringify(current)).not.toContain("new-admin-key"); + }); + + it("restores the provider current version when external value rotation persistence fails", 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 externalRef = "arn:aws:secretsmanager:us-east-1:123456789012:secret:shared/rollback"; + const secret = await svc.create(companyId, { + name: "External rollback", + key: "external-rollback", + provider: "aws_secrets_manager", + providerConfigId: awsVault.id, + managedMode: "external_reference", + externalRef, + }); + const prepared = { + material: { + scheme: "aws_secrets_manager_v1", + secretId: externalRef, + versionId: null, + source: "external_reference", + lastWrittenVersionId: "aws-version-2", + previousCurrentVersionId: "aws-version-1", + }, + valueSha256: "a".repeat(64), + fingerprintSha256: "a".repeat(64), + externalRef, + providerVersionRef: null, + }; + vi.spyOn(awsSecretsManagerProvider, "updateExternalSecretValue").mockResolvedValueOnce(prepared); + const rollbackSpy = vi.spyOn(awsSecretsManagerProvider, "deleteOrArchive").mockResolvedValue(); + vi.spyOn(db, "transaction").mockRejectedValueOnce(new Error("db rotate failed")); + + await expect(svc.rotate(secret.id, { value: "new-value" })).rejects.toThrow( + "db rotate failed", + ); + + expect(rollbackSpy).toHaveBeenCalledWith(expect.objectContaining({ + material: prepared.material, + externalRef, + mode: "archive", + providerConfig: expect.objectContaining({ id: awsVault.id }), + context: { + companyId, + secretKey: "external-rollback", + secretName: "External rollback", + version: 2, + }, + })); + }); + + it("rejects external value rotations that also retarget or pin versions", 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/neon-admin", + }); + + await expect( + svc.rotate(secret.id, { + value: "new-admin-key", + externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:shared/other", + }), + ).rejects.toThrow(/not both/); + await expect( + svc.rotate(secret.id, { value: "new-admin-key", providerVersionRef: "pinned-1" }), + ).rejects.toThrow(/cannot pin/i); + }); + + it("rejects external value rotations when the provider cannot write values", async () => { + const companyId = await seedCompany(); + const svc = secretService(db); + const secret = await svc.create(companyId, { + name: `external-${randomUUID()}`, + provider: "vault", + managedMode: "external_reference", + externalRef: "kv/data/shared/neon-admin", + }); + + await expect(svc.rotate(secret.id, { value: "new-admin-key" })).rejects.toThrow( + /does not support writing values/, + ); + }); + it("imports AWS remote references row-by-row without fetching plaintext", async () => { const companyId = await seedCompany(); const svc = secretService(db); diff --git a/server/src/secrets/aws-secrets-manager-provider.ts b/server/src/secrets/aws-secrets-manager-provider.ts index a697556a169..87090c86c53 100644 --- a/server/src/secrets/aws-secrets-manager-provider.ts +++ b/server/src/secrets/aws-secrets-manager-provider.ts @@ -36,6 +36,8 @@ interface AwsSecretsManagerMaterial extends StoredSecretVersionMaterial { secretId: string; versionId: string | null; source: "managed" | "external_reference"; + lastWrittenVersionId?: string | null; + previousCurrentVersionId?: string | null; } interface AwsSecretsManagerConfig { @@ -264,6 +266,7 @@ function configuredAwsSecretsManagerDescriptor() { requiresExternalRef: false, supportsManagedValues: true, supportsExternalReferences: true, + supportsExternalValueWrites: true, configured: canLoadAwsSecretsManagerConfig(), }; } @@ -1179,6 +1182,42 @@ export function createAwsSecretsManagerProvider( assertNotManagedNamespaceExternalRef(config, input.externalRef); return createExternalReferenceMaterial(input.externalRef, input.providerVersionRef ?? null); }, + async updateExternalSecretValue(input) { + const config = resolveConfig(input.providerConfig); + assertNotManagedNamespaceExternalRef(config, input.externalRef); + const gateway = resolveGateway(config); + const secretId = input.externalRef.trim(); + + try { + const previous = await gateway.getSecretValue({ + SecretId: secretId, + VersionStage: DEFAULT_VERSION_STAGE, + }); + // No VersionStages: the new version becomes AWSCURRENT so every consumer of the + // referenced secret (not just Paperclip) picks up the new value. + const created = await gateway.putSecretValue({ + SecretId: secretId, + SecretString: input.value, + }); + const normalizedSecretId = created.ARN ?? created.Name ?? secretId; + // Material keeps versionId null so resolution keeps tracking AWSCURRENT and + // out-of-band rotations done directly in AWS still flow through. + const prepared = createExternalReferenceMaterial(normalizedSecretId, null); + const valueSha256 = sha256Hex(input.value); + return { + ...prepared, + material: { + ...prepared.material, + lastWrittenVersionId: created.VersionId ?? null, + previousCurrentVersionId: previous.VersionId ?? null, + }, + valueSha256, + fingerprintSha256: valueSha256, + }; + } catch (error) { + normalizeAwsError("updateExternalSecretValue", error); + } + }, async listRemoteSecrets(input): Promise { const config = resolveConfig(input.providerConfig); const gateway = resolveGateway(config); @@ -1277,10 +1316,31 @@ export function createAwsSecretsManagerProvider( ? asAwsSecretsManagerMaterial(input.material) : null; - if (material?.source !== "managed") return; - const config = resolveConfig(input.providerConfig); const gateway = resolveGateway(config); + + if (material?.source === "external_reference") { + if ( + input.mode === "archive" && + material.lastWrittenVersionId && + material.previousCurrentVersionId && + gateway.updateSecretVersionStage + ) { + try { + await gateway.updateSecretVersionStage({ + SecretId: material.secretId, + VersionStage: DEFAULT_VERSION_STAGE, + MoveToVersionId: material.previousCurrentVersionId, + RemoveFromVersionId: material.lastWrittenVersionId, + }); + } catch (error) { + normalizeAwsError("updateSecretVersionStage", error); + } + } + return; + } + if (material?.source !== "managed") return; + const secretId = resolveManagedSecretRef({ config, context: input.context, diff --git a/server/src/secrets/types.ts b/server/src/secrets/types.ts index 9edf7766be1..19ffd7359cd 100644 --- a/server/src/secrets/types.ts +++ b/server/src/secrets/types.ts @@ -150,6 +150,12 @@ export interface SecretProviderModule { context?: SecretProviderWriteContext; providerConfig?: SecretProviderVaultRuntimeConfig | null; }): Promise; + updateExternalSecretValue?(input: { + externalRef: string; + value: string; + context?: SecretProviderWriteContext; + providerConfig?: SecretProviderVaultRuntimeConfig | null; + }): Promise; listRemoteSecrets?(input: { providerConfig?: SecretProviderVaultRuntimeConfig | null; query?: string | null; diff --git a/server/src/services/secrets.ts b/server/src/services/secrets.ts index 3c2c8210056..b59f3a73b61 100644 --- a/server/src/services/secrets.ts +++ b/server/src/services/secrets.ts @@ -3606,6 +3606,27 @@ export function secretService(db: Db) { providerConfigId, }); const nextVersion = secret.latestVersion + 1; + const externalValueWrite = + secret.managedMode === "external_reference" && Boolean(input.value?.trim()); + if (externalValueWrite) { + const currentRef = secret.externalRef?.trim(); + if (!currentRef) { + throw unprocessable("External reference secrets require externalRef"); + } + if (input.externalRef?.trim() && input.externalRef.trim() !== currentRef) { + throw unprocessable( + "Provide either a new value or a new external reference, not both", + ); + } + if (input.providerVersionRef?.trim()) { + throw unprocessable("Value updates cannot pin providerVersionRef"); + } + if (!provider.updateExternalSecretValue) { + throw unprocessable( + `${provider.descriptor().label} does not support writing values to external reference secrets`, + ); + } + } if (secret.managedMode === "external_reference" && !(input.externalRef ?? secret.externalRef)?.trim()) { throw unprocessable("External reference secrets require externalRef"); } @@ -3623,8 +3644,14 @@ export function secretService(db: Db) { }; let prepared: PreparedSecretVersion; try { - prepared = - secret.managedMode === "external_reference" + prepared = externalValueWrite + ? await provider.updateExternalSecretValue!({ + externalRef: secret.externalRef ?? "", + value: input.value ?? "", + providerConfig, + context: providerWriteContext, + }) + : secret.managedMode === "external_reference" ? await provider.linkExternalSecret({ externalRef: input.externalRef ?? secret.externalRef ?? "", providerVersionRef: input.providerVersionRef ?? null, @@ -3660,7 +3687,7 @@ export function secretService(db: Db) { createdByUserId: actor?.userId ?? null, }); } catch (error) { - if (secret.managedMode !== "external_reference") { + if (secret.managedMode !== "external_reference" || externalValueWrite) { await cleanupPreparedProviderWrite({ provider, prepared, @@ -3707,7 +3734,7 @@ export function secretService(db: Db) { return updated; }); } catch (error) { - if (secret.managedMode !== "external_reference") { + if (secret.managedMode !== "external_reference" || externalValueWrite) { const cleaned = await cleanupPreparedProviderWrite({ provider, prepared, diff --git a/ui/src/pages/Secrets.render.test.tsx b/ui/src/pages/Secrets.render.test.tsx index d96f4bf4687..05fcf192076 100644 --- a/ui/src/pages/Secrets.render.test.tsx +++ b/ui/src/pages/Secrets.render.test.tsx @@ -1,6 +1,6 @@ // @vitest-environment jsdom -import { createRoot } from "react-dom/client"; +import { createRoot as createReactRoot, type Root } from "react-dom/client"; import { flushSync } from "react-dom"; import { MemoryRouter } from "react-router-dom"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; @@ -114,6 +114,7 @@ const providers: SecretProviderDescriptor[] = [ requiresExternalRef: false, supportsManagedValues: true, supportsExternalReferences: true, + supportsExternalValueWrites: true, configured: true, }, { @@ -159,12 +160,29 @@ const providerConfigs = [ }, ] satisfies Partial[]; +const activeRoots = new Set(); + async function act(callback: () => void | Promise) { - let result: void | Promise = undefined; + let result: void | Promise; flushSync(() => { result = callback(); }); - await result; + await result!; +} + +function createRoot(container: Element | DocumentFragment) { + const root = createReactRoot(container); + const unmount = root.unmount.bind(root); + root.unmount = () => { + if (!activeRoots.delete(root)) return; + unmount(); + }; + activeRoots.add(root); + return root; +} + +function unmountActiveRoots() { + for (const root of [...activeRoots]) root.unmount(); } async function flushReact() { @@ -368,6 +386,7 @@ describe("Secrets page layout", () => { }); afterEach(() => { + unmountActiveRoots(); container.remove(); document.body.innerHTML = ""; vi.clearAllMocks(); @@ -602,13 +621,14 @@ describe("Secrets page layout", () => { }); await flushReact(); + await waitForReact(() => document.body.textContent?.includes("View in Usage") ?? false); const viewUsageButton = Array.from(document.body.querySelectorAll("button")).find( (button) => button.textContent?.includes("View in Usage"), ) as HTMLButtonElement | undefined; await act(async () => { viewUsageButton?.click(); }); - await flushReact(); + await waitForReact(() => mockSecretsApi.usage.mock.calls.length > 0); expect(mockSecretsApi.usage).toHaveBeenCalledWith("secret-openai"); expect(document.body.textContent).toContain("CodexCoder"); @@ -619,6 +639,112 @@ describe("Secrets page layout", () => { }); }); + it("opens the secret detail sheet from a ?secret= deep link", async () => { + mockSecretsApi.list.mockResolvedValue([makeCompanySecret()]); + mockSecretsApi.usage.mockResolvedValue({ secretId: "secret-openai", bindings: [] }); + mockSecretsApi.accessEvents.mockResolvedValue([]); + + const root = createRoot(container); + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + + await act(async () => { + root.render( + + + + + , + ); + }); + await flushReact(); + await flushReact(); + + // The detail sheet is open without any clicks: the deep link drives selection. + const copyLinkButton = Array.from(document.body.querySelectorAll("button")).find( + (button) => button.textContent?.includes("Copy link"), + ); + expect(copyLinkButton).not.toBeUndefined(); + const sheet = copyLinkButton?.closest("[role='dialog']"); + expect(sheet?.textContent).toContain("OPENAI_API_KEY"); + + await act(async () => { + root.unmount(); + }); + }); + + it("writes a new value through the provider for external reference secrets", async () => { + const externalSecret = makeCompanySecret({ + id: "secret-neon", + key: "neon_admin_api_key", + name: "paperclip-cloud/prod/provider/neon/admin-api-key", + provider: "aws_secrets_manager", + managedMode: "external_reference", + externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip-cloud/prod/provider/neon/admin-api-key", + providerConfigId: "vault-aws", + }); + mockSecretsApi.list.mockResolvedValue([externalSecret]); + mockSecretsApi.usage.mockResolvedValue({ secretId: "secret-neon", bindings: [] }); + mockSecretsApi.accessEvents.mockResolvedValue([]); + mockSecretsApi.rotate.mockResolvedValue({ ...externalSecret, latestVersion: 2 }); + + const root = createRoot(container); + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + + await act(async () => { + root.render( + + + + + , + ); + }); + await flushReact(); + await flushReact(); + + // AWS supports write-through, so the primary action is a value update. + const updateValueButton = Array.from(document.body.querySelectorAll("button")).find( + (button) => button.textContent?.trim() === "Update value", + ) as HTMLButtonElement | undefined; + expect(updateValueButton).not.toBeUndefined(); + await act(async () => { + updateValueButton?.click(); + }); + await flushReact(); + + // Both modes are offered; "Write new value" is the default. + expect(document.body.textContent).toContain("Write new value"); + expect(document.body.textContent).toContain("Change reference"); + + const valueTextarea = document.getElementById("rotate-value") as HTMLTextAreaElement | null; + expect(valueTextarea).not.toBeNull(); + setTextareaValue(valueTextarea!, "rotated-neon-admin-key"); + await flushReact(); + + const dialog = valueTextarea!.closest("[role='dialog']") as HTMLElement; + const submitButton = Array.from(dialog.querySelectorAll("button")).find( + (button) => button.textContent?.trim() === "Update value", + ) as HTMLButtonElement | undefined; + expect(submitButton?.disabled).toBe(false); + await act(async () => { + submitButton?.click(); + }); + await flushReact(); + + expect(mockSecretsApi.rotate).toHaveBeenCalledWith("secret-neon", { + value: "rotated-neon-admin-key", + providerConfigId: "vault-aws", + }); + + await act(async () => { + root.unmount(); + }); + }); + it("merges company secrets and each-user definitions into the Secrets list", async () => { mockSecretsApi.list.mockResolvedValue([makeCompanySecret()]); mockSecretsApi.listUserSecretDefinitions.mockResolvedValue([makeUserSecretDefinition()]); @@ -822,7 +948,7 @@ describe("Secrets page layout", () => { await act(async () => { definitionRow?.click(); }); - await flushReact(); + await waitForReact(() => document.body.textContent?.includes("Details") ?? false); expect(document.body.textContent).toContain("Personal GitHub token"); expect(document.body.textContent).toContain("Details"); @@ -1350,8 +1476,7 @@ describe("Secrets page layout", () => { await act(async () => { companyRow?.click(); }); - await flushReact(); - await flushReact(); + await waitForReact(() => document.body.textContent?.includes("Reviewer") ?? false); // Existing access is listed right in the Details tab. expect(document.body.textContent).toContain("Agent access"); @@ -1506,6 +1631,7 @@ describe("Secrets folder view (PAP-14698)", () => { }); afterEach(() => { + unmountActiveRoots(); container.remove(); document.body.innerHTML = ""; vi.clearAllMocks(); diff --git a/ui/src/pages/Secrets.tsx b/ui/src/pages/Secrets.tsx index 112301986d0..14167e3c5f3 100644 --- a/ui/src/pages/Secrets.tsx +++ b/ui/src/pages/Secrets.tsx @@ -127,6 +127,9 @@ import { import type { MyUserSecretEntry } from "../api/secrets"; type CreateMode = "managed" | "external"; +// "value" writes a new secret value (for external references: through to the +// provider); "reference" re-points an external reference without writing. +type RotateMode = "value" | "reference"; type SecretValueProvider = "company" | "user"; type ProvidedByFilter = "all" | SecretValueProvider; type SecretsTab = "secrets" | "my-secrets" | "vaults"; @@ -341,9 +344,12 @@ 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." +function modeDescription(managedMode: SecretManagedMode, canWriteExternalValue = false) { + if (managedMode === "paperclip_managed") { + return "Paperclip owns create and rotation writes for this provider secret."; + } + return canWriteExternalValue + ? "Paperclip resolves this provider reference and can write new values to it via Update value." : "Paperclip resolves this provider reference but does not rotate the provider value."; } @@ -650,8 +656,25 @@ export function Secrets() { const [statusFilter, setStatusFilter] = useState("active"); const [providerFilter, setProviderFilter] = useState("all"); const [providedByFilter, setProvidedByFilter] = useState("all"); - const [selectedSecretId, setSelectedSecretId] = useState(null); - const [selectedDefinitionId, setSelectedDefinitionId] = useState(null); + const [searchParams, setSearchParams] = useSearchParams(); + // The detail sheet is deep-linkable: `?secret=` / + // `?definition=` are the source of truth for the current selection, so + // every open secret has a shareable URL and Back closes the sheet. + const selectedSecretId = searchParams.get("secret"); + const selectedDefinitionId = searchParams.get("definition"); + const setDetailSelection = useCallback( + (secretId: string | null, definitionId: string | null = null) => { + setSearchParams((prev) => { + const next = new URLSearchParams(prev); + if (secretId) next.set("secret", secretId); + else next.delete("secret"); + if (definitionId) next.set("definition", definitionId); + else next.delete("definition"); + return next; + }); + }, + [setSearchParams], + ); const [usageDialogSecretId, setUsageDialogSecretId] = useState(null); const [createOpen, setCreateOpen] = useState(false); const [importOpen, setImportOpen] = useState(false); @@ -674,6 +697,7 @@ export function Secrets() { }); const [createError, setCreateError] = useState(null); const [rotateOpen, setRotateOpen] = useState(false); + const [rotateMode, setRotateMode] = useState("value"); const [rotateValue, setRotateValue] = useState(""); const [rotateExternalRef, setRotateExternalRef] = useState(""); const [rotateProviderConfigId, setRotateProviderConfigId] = useState(""); @@ -868,7 +892,6 @@ export function Secrets() { // Folders are derived purely from slash-delimited secret names; there is no // server-side folder record. `?path=` holds the normalized current folder // and is only meaningful on the main Secrets tab (inert on the others). - const [searchParams, setSearchParams] = useSearchParams(); const pathParam = normalizeSecretPath(searchParams.get("path") ?? ""); const folderPath = activeTab === "secrets" ? pathParam : ""; const searching = search.trim().length > 0; @@ -1094,12 +1117,10 @@ export function Secrets() { }); setCreateError(null); if (result.kind === "company") { - setSelectedSecretId(result.item.id); - setSelectedDefinitionId(null); + setDetailSelection(result.item.id); invalidateAll([result.item.id]); } else { - setSelectedDefinitionId(result.item.id); - setSelectedSecretId(null); + setDetailSelection(null, result.item.id); invalidateAll([result.item.id]); } }, @@ -1111,7 +1132,7 @@ export function Secrets() { const rotateMutation = useMutation({ mutationFn: () => { if (!selectedSecret) throw new Error("Select a secret first"); - if (selectedSecret.managedMode === "external_reference") { + if (selectedSecret.managedMode === "external_reference" && rotateMode === "reference") { return secretsApi.rotate(selectedSecret.id, { externalRef: rotateExternalRef.trim() || selectedSecret.externalRef || undefined, providerConfigId: rotateProviderConfigId || null, @@ -1183,7 +1204,7 @@ export function Secrets() { onSuccess: (_response, id) => { pushToast({ title: "Secret deleted", tone: "info" }); setDeleteConfirm(null); - if (selectedSecretId === id) setSelectedSecretId(null); + if (selectedSecretId === id) setDetailSelection(null); invalidateAll([id]); }, onError: (error) => { @@ -1201,7 +1222,7 @@ export function Secrets() { onSuccess: (_response, definition) => { pushToast({ title: "User-provided secret removed", body: definition.name, tone: "info" }); setDefinitionDeleteConfirm(null); - if (selectedDefinitionId === definition.id) setSelectedDefinitionId(null); + if (selectedDefinitionId === definition.id) setDetailSelection(null); invalidateAll([definition.id]); }, onError: (error) => { @@ -1409,19 +1430,36 @@ export function Secrets() { function openCompanySecret(secret: CompanySecret) { setSecretDetailTab("details"); - setSelectedSecretId(secret.id); - setSelectedDefinitionId(null); + setDetailSelection(secret.id); } function openUserDefinition(definition: UserSecretDefinition) { setSecretDetailTab("details"); - setSelectedDefinitionId(definition.id); - setSelectedSecretId(null); + setDetailSelection(null, definition.id); + } + + function secretSupportsExternalValueWrite(secret: CompanySecret) { + return ( + secret.managedMode === "external_reference" && + Boolean(secret.externalRef) && + Boolean(providers.find((provider) => provider.id === secret.provider)?.supportsExternalValueWrites) + ); + } + + function rotateActionLabel(secret: CompanySecret) { + return secret.managedMode === "external_reference" && !secretSupportsExternalValueWrite(secret) + ? "Update reference" + : "Update value"; } function openRotateSecret(secret: CompanySecret) { openCompanySecret(secret); setRotateOpen(true); + setRotateMode( + secret.managedMode === "external_reference" && !secretSupportsExternalValueWrite(secret) + ? "reference" + : "value", + ); setRotateValue(""); setRotateExternalRef(""); setRotateProviderConfigId( @@ -1430,6 +1468,18 @@ export function Secrets() { setRotateError(null); } + function copyDetailLink() { + void copyTextToClipboard(window.location.href) + .then(() => pushToast({ title: "Link copied", body: "Deep link to this secret", tone: "success" })) + .catch((error) => + pushToast({ + title: "Copy failed", + body: error instanceof Error ? error.message : "Unable to copy link", + tone: "error", + }), + ); + } + function copySecretKey(key: string) { void copyTextToClipboard(key) .then(() => pushToast({ title: "Secret key copied", body: key, tone: "success" })) @@ -1472,7 +1522,7 @@ export function Secrets() { openRotateSecret(row.secret)}> - {row.secret.managedMode === "external_reference" ? "Update reference" : "Update value"} + {rotateActionLabel(row.secret)} { - if (!open) { - setSelectedSecretId(null); - setSelectedDefinitionId(null); - } + if (!open && (selectedSecret || selectedDefinition)) setDetailSelection(null); }} > @@ -2188,7 +2235,10 @@ export function Secrets() { onClick={() => openRotateSecret(selectedSecret)} > - {selectedSecret.managedMode === "external_reference" ? "Update reference" : "Update value"} + {rotateActionLabel(selectedSecret)} + + @@ -2964,14 +3014,26 @@ export function Secrets() { - {selectedSecret?.managedMode === "external_reference" ? "Update external reference" : "Update secret value"} + {selectedSecret?.managedMode === "external_reference" && rotateMode === "reference" + ? "Update external reference" + : "Update secret value"} - {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."} + {selectedSecret?.managedMode !== "external_reference" + ? "Creates a new provider-backed version. Consumers pinned to latest pick up the new value on the next run." + : rotateMode === "reference" + ? "Creates a new Paperclip metadata version that points at an existing provider secret. Paperclip does not write a new provider value." + : "Writes a new version of the referenced provider secret. The new value becomes current for every consumer of that secret, in and outside Paperclip."} + {selectedSecret && secretSupportsExternalValueWrite(selectedSecret) ? ( + setRotateMode(value as RotateMode)}> + + Write new value + Change reference + + + ) : null}
+ {selectedSecret?.managedMode === "external_reference" ? ( +

+ Written to {selectedSecret.externalRef} in the provider. +

+ ) : null}
)} {rotateError ?

{rotateError}

: null} @@ -3040,13 +3107,15 @@ export function Secrets() { disabled={ rotateMutation.isPending || Boolean(rotateProviderBlockReason) || - (selectedSecret?.managedMode === "external_reference" + (selectedSecret?.managedMode === "external_reference" && rotateMode === "reference" ? !rotateExternalRef.trim() && !selectedSecret?.externalRef : !rotateValue) } > {rotateMutation.isPending ? : null} - {selectedSecret?.managedMode === "external_reference" ? "Update reference" : "Update value"} + {selectedSecret?.managedMode === "external_reference" && rotateMode === "reference" + ? "Update reference" + : "Update value"}
@@ -4546,7 +4615,14 @@ function SecretDetailsTab({ {formatRelative(secret.lastRotatedAt)} {formatRelative(secret.lastResolvedAt)}
- {modeDescription(secret.managedMode)} Paperclip never re-displays stored values. + {modeDescription( + secret.managedMode, + Boolean( + secret.externalRef && + providers.find((provider) => provider.id === secret.provider)?.supportsExternalValueWrites, + ), + )}{" "} + Paperclip never re-displays stored values.
);