diff --git a/.server-changes/mark-environment-variables-secret.md b/.server-changes/mark-environment-variables-secret.md new file mode 100644 index 00000000000..7824628cb08 --- /dev/null +++ b/.server-changes/mark-environment-variables-secret.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: feature +--- + +Existing environment variables can now be permanently marked as secret from the dashboard. diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables/route.tsx index 1974e2051fb..a4b61275aba 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables/route.tsx @@ -20,7 +20,15 @@ import { import { json } from "@remix-run/server-runtime"; import { useVirtualizer } from "@tanstack/react-virtual"; import { fromPromise } from "neverthrow"; -import { useEffect, useLayoutEffect, useMemo, useRef, useState, type RefObject } from "react"; +import { + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, + type FormEvent, + type RefObject, +} from "react"; import { typedjson, useTypedLoaderData } from "remix-typedjson"; import { z } from "zod"; import { UserAvatar } from "~/components/UserProfilePhoto"; @@ -36,6 +44,7 @@ import { Fieldset } from "~/components/primitives/Fieldset"; import { FormButtons } from "~/components/primitives/FormButtons"; import { FormError } from "~/components/primitives/FormError"; import { Header2 } from "~/components/primitives/Headers"; +import { Hint } from "~/components/primitives/Hint"; import { Input } from "~/components/primitives/Input"; import { InputGroup } from "~/components/primitives/InputGroup"; import { Label } from "~/components/primitives/Label"; @@ -808,11 +817,32 @@ function EditEnvironmentVariablePanel({ revealAll: boolean; }) { const [isOpen, setIsOpen] = useState(false); + const [isSecret, setIsSecret] = useState(variable.isSecret); const fetcher = useFetcher(); const lastSubmission = fetcher.data as any; const isLoading = fetcher.state !== "idle"; + function handleOpenChange(open: boolean) { + if (open) { + setIsSecret(variable.isSecret); + } + + setIsOpen(open); + } + + function handleSubmit(event: FormEvent) { + if ( + isSecret && + !variable.isSecret && + !window.confirm( + "Making this variable secret is irreversible. The value will be hidden and cannot be revealed again. Continue?" + ) + ) { + event.preventDefault(); + } + } + // Close dialog on successful submission useEffect(() => { if (lastSubmission?.success && fetcher.state === "idle") { @@ -832,14 +862,15 @@ function EditEnvironmentVariablePanel({ }); return ( - + } diff --git a/apps/webapp/app/v3/environmentVariables/environmentVariablesRepository.server.ts b/apps/webapp/app/v3/environmentVariables/environmentVariablesRepository.server.ts index b38e069e637..621c176f8e0 100644 --- a/apps/webapp/app/v3/environmentVariables/environmentVariablesRepository.server.ts +++ b/apps/webapp/app/v3/environmentVariables/environmentVariablesRepository.server.ts @@ -505,6 +505,7 @@ export class EnvironmentVariablesRepository implements Repository { increment: 1, }, lastUpdatedBy: options.lastUpdatedBy ? options.lastUpdatedBy : undefined, + isSecret: options.isSecret ? true : undefined, }, }); }); diff --git a/apps/webapp/app/v3/environmentVariables/repository.ts b/apps/webapp/app/v3/environmentVariables/repository.ts index ba0d70b6e04..036fdd18bb8 100644 --- a/apps/webapp/app/v3/environmentVariables/repository.ts +++ b/apps/webapp/app/v3/environmentVariables/repository.ts @@ -69,6 +69,10 @@ export const EditEnvironmentVariableValue = z.object({ environmentId: z.string(), value: z.string(), lastUpdatedBy: EnvironmentVariableUpdaterSchema.optional(), + isSecret: z.preprocess( + (val) => (val === undefined ? undefined : val === "true" || val === true), + z.boolean().optional() + ), }); export type EditEnvironmentVariableValue = z.infer; diff --git a/apps/webapp/test/environmentVariablesRepository.test.ts b/apps/webapp/test/environmentVariablesRepository.test.ts index b5661c89cbe..d0ca3c1a593 100644 --- a/apps/webapp/test/environmentVariablesRepository.test.ts +++ b/apps/webapp/test/environmentVariablesRepository.test.ts @@ -257,3 +257,112 @@ describe("EnvironmentVariablesRepository.getVariableValuesForKeys", () => { } ); }); + +describe("EnvironmentVariablesRepository.editValue", () => { + postgresTest( + "permanently marks an existing value as secret while updating its value", + async ({ prisma }) => { + const { user, organization, project } = await createTestOrgProjectWithMember(prisma); + const environment = await createRuntimeEnvironment(prisma, { + projectId: project.id, + organizationId: organization.id, + type: "PRODUCTION", + }); + const repository = new EnvironmentVariablesRepository(prisma, prisma); + + await createEnvironmentVariable(repository, project.id, { + environmentId: environment.id, + key: "BECOMES_SECRET", + value: "plain-value", + userId: user.id, + }); + + const variable = await prisma.environmentVariable.findFirstOrThrow({ + where: { projectId: project.id, key: "BECOMES_SECRET" }, + include: { values: { where: { environmentId: environment.id } } }, + }); + const originalVersion = variable.values[0]!.version; + + const result = await repository.editValue(project.id, { + id: variable.id, + environmentId: environment.id, + value: "new-secret-value", + isSecret: true, + lastUpdatedBy: { type: "user", userId: user.id }, + }); + + expect(result).toEqual({ success: true }); + + const updatedValue = await prisma.environmentVariableValue.findUniqueOrThrow({ + where: { + variableId_environmentId: { + variableId: variable.id, + environmentId: environment.id, + }, + }, + }); + expect(updatedValue.isSecret).toBe(true); + expect(updatedValue.version).toBe(originalVersion + 1); + + const unredacted = await repository.getEnvironment(project.id, environment.id); + expect(unredacted).toEqual([ + expect.objectContaining({ key: "BECOMES_SECRET", value: "new-secret-value" }), + ]); + + const redacted = await repository.getEnvironmentWithRedactedSecrets( + project.id, + environment.id + ); + expect(redacted).toEqual([ + expect.objectContaining({ key: "BECOMES_SECRET", value: "", isSecret: true }), + ]); + } + ); + + postgresTest("does not change an existing secret value back to plaintext", async ({ prisma }) => { + const { user, organization, project } = await createTestOrgProjectWithMember(prisma); + const environment = await createRuntimeEnvironment(prisma, { + projectId: project.id, + organizationId: organization.id, + type: "PRODUCTION", + }); + const repository = new EnvironmentVariablesRepository(prisma, prisma); + + await createEnvironmentVariable(repository, project.id, { + environmentId: environment.id, + key: "STAYS_SECRET", + value: "original-secret", + isSecret: true, + userId: user.id, + }); + + const variable = await prisma.environmentVariable.findFirstOrThrow({ + where: { projectId: project.id, key: "STAYS_SECRET" }, + }); + + const result = await repository.editValue(project.id, { + id: variable.id, + environmentId: environment.id, + value: "updated-secret", + isSecret: false, + lastUpdatedBy: { type: "user", userId: user.id }, + }); + + expect(result).toEqual({ success: true }); + + const updatedValue = await prisma.environmentVariableValue.findUniqueOrThrow({ + where: { + variableId_environmentId: { + variableId: variable.id, + environmentId: environment.id, + }, + }, + }); + expect(updatedValue.isSecret).toBe(true); + + const unredacted = await repository.getEnvironment(project.id, environment.id); + expect(unredacted).toEqual([ + expect.objectContaining({ key: "STAYS_SECRET", value: "updated-secret" }), + ]); + }); +});