diff --git a/apps/dokploy/__test__/env/vault.test.ts b/apps/dokploy/__test__/env/vault.test.ts index 8465e4ae5..212b070bd 100644 --- a/apps/dokploy/__test__/env/vault.test.ts +++ b/apps/dokploy/__test__/env/vault.test.ts @@ -36,17 +36,25 @@ beforeEach(() => { mockFetch.mockReset(); }); +const scope = { + organizationId: "org-1", + projectId: "proj-1", + environmentId: "env-1", +}; + +const assignedEverywhere = [{ projectId: "proj-1", environmentIds: [] }]; + describe("resolveVaultReferences", () => { it("returns input untouched and skips the db when no refs are present", async () => { const env = "FOO=bar\nBAZ=${{project.QUX}}"; - const result = await resolveVaultReferences(env, "org-1"); + const result = await resolveVaultReferences(env, scope); expect(result).toBe(env); expect(findMany).not.toHaveBeenCalled(); }); it("returns null/empty inputs unchanged", async () => { - expect(await resolveVaultReferences(null, "org-1")).toBeNull(); - expect(await resolveVaultReferences("", "org-1")).toBe(""); + expect(await resolveVaultReferences(null, scope)).toBeNull(); + expect(await resolveVaultReferences("", scope)).toBe(""); }); it("throws when refs exist but no organization context is given", async () => { @@ -58,16 +66,64 @@ describe("resolveVaultReferences", () => { it("throws for an unknown provider", async () => { findMany.mockResolvedValue([]); await expect( - resolveVaultReferences("FOO=${{vault.missing.SECRET}}", "org-1"), + resolveVaultReferences("FOO=${{vault.missing.SECRET}}", scope), ).rejects.toThrow('Vault provider "missing" not found'); }); + it("throws when the provider is not assigned to the project", async () => { + findMany.mockResolvedValue([ + { + name: "prod", + providerType: "doppler", + config: { providerType: "doppler", serviceToken: "dp.st.token" }, + assignments: [{ projectId: "other-project", environmentIds: [] }], + }, + ]); + await expect( + resolveVaultReferences("FOO=${{vault.prod.SECRET}}", scope), + ).rejects.toThrow("not enabled for this project/environment"); + }); + + it("throws when the provider is restricted to another environment", async () => { + findMany.mockResolvedValue([ + { + name: "prod", + providerType: "doppler", + config: { providerType: "doppler", serviceToken: "dp.st.token" }, + assignments: [ + { projectId: "proj-1", environmentIds: ["production-env"] }, + ], + }, + ]); + await expect( + resolveVaultReferences("FOO=${{vault.prod.SECRET}}", scope), + ).rejects.toThrow("not enabled for this project/environment"); + }); + + it("allows a provider restricted to the matching environment", async () => { + findMany.mockResolvedValue([ + { + name: "prod", + providerType: "doppler", + config: { providerType: "doppler", serviceToken: "dp.st.token" }, + assignments: [{ projectId: "proj-1", environmentIds: ["env-1"] }], + }, + ]); + mockFetch.mockResolvedValue(jsonResponse({ SECRET: "value-1" })); + const result = await resolveVaultReferences( + "FOO=${{vault.prod.SECRET}}", + scope, + ); + expect(result).toBe("FOO=value-1"); + }); + it("resolves refs through a doppler provider", async () => { findMany.mockResolvedValue([ { name: "doppler-prod", providerType: "doppler", config: { providerType: "doppler", serviceToken: "dp.st.token" }, + assignments: assignedEverywhere, }, ]); mockFetch.mockResolvedValue( @@ -76,7 +132,7 @@ describe("resolveVaultReferences", () => { const result = await resolveVaultReferences( "DB_URL=${{vault.doppler-prod.DB_URL}}\nAPI_KEY=${{vault.doppler-prod.API_KEY}}", - "org-1", + scope, ); expect(result).toBe("DB_URL=postgres://real\nAPI_KEY=key-123"); @@ -89,12 +145,13 @@ describe("resolveVaultReferences", () => { name: "doppler-prod", providerType: "doppler", config: { providerType: "doppler", serviceToken: "dp.st.token" }, + assignments: assignedEverywhere, }, ]); mockFetch.mockResolvedValue(jsonResponse({ OTHER: "x" })); await expect( - resolveVaultReferences("FOO=${{vault.doppler-prod.MISSING}}", "org-1"), + resolveVaultReferences("FOO=${{vault.doppler-prod.MISSING}}", scope), ).rejects.toThrow('secret "MISSING" not found'); }); }); @@ -106,6 +163,7 @@ describe("withResolvedVaultRefs + prepareEnvironmentVariables", () => { name: "prod", providerType: "doppler", config: { providerType: "doppler", serviceToken: "dp.st.token" }, + assignments: assignedEverywhere, }, ]); mockFetch.mockResolvedValue(jsonResponse({ DB_PASSWORD: "s3cret" })); @@ -113,8 +171,10 @@ describe("withResolvedVaultRefs + prepareEnvironmentVariables", () => { const entity = { env: "DATABASE_URL=postgres://user:${{project.DB_PASSWORD}}@db", environment: { + environmentId: "env-1", env: null, project: { + projectId: "proj-1", env: "DB_PASSWORD=${{vault.prod.DB_PASSWORD}}", organizationId: "org-1", }, @@ -137,6 +197,7 @@ describe("withResolvedVaultRefs + prepareEnvironmentVariables", () => { name: "prod", providerType: "doppler", config: { providerType: "doppler", serviceToken: "dp.st.token" }, + assignments: assignedEverywhere, }, ]); mockFetch.mockResolvedValue(jsonResponse({ NPM_TOKEN: "npm-123" })); @@ -146,8 +207,9 @@ describe("withResolvedVaultRefs + prepareEnvironmentVariables", () => { buildArgs: "NPM_TOKEN=${{vault.prod.NPM_TOKEN}}", buildSecrets: null, environment: { + environmentId: "env-1", env: null, - project: { env: null, organizationId: "org-1" }, + project: { projectId: "proj-1", env: null, organizationId: "org-1" }, }, }); @@ -156,6 +218,70 @@ describe("withResolvedVaultRefs + prepareEnvironmentVariables", () => { expect(resolved.env).toBe("FOO=bar"); }); + it.each([ + ["service env", { env: "SECRET=${{vault.locked.X}}" }], + ["environment env", { environmentEnv: "SECRET=${{vault.locked.X}}" }], + ["project env", { projectEnv: "SECRET=${{vault.locked.X}}" }], + ["build args", { buildArgs: "SECRET=${{vault.locked.X}}" }], + ])( + "rejects a ref to an unassigned provider placed in the %s", + async (_source, overrides) => { + findMany.mockResolvedValue([ + { + name: "locked", + providerType: "doppler", + config: { providerType: "doppler", serviceToken: "dp.st.token" }, + assignments: [{ projectId: "other-project", environmentIds: [] }], + }, + ]); + + const entity = { + env: (overrides as { env?: string }).env ?? "FOO=bar", + buildArgs: (overrides as { buildArgs?: string }).buildArgs ?? null, + environment: { + environmentId: "env-1", + env: + (overrides as { environmentEnv?: string }).environmentEnv ?? null, + project: { + projectId: "proj-1", + env: (overrides as { projectEnv?: string }).projectEnv ?? null, + organizationId: "org-1", + }, + }, + }; + + await expect(withResolvedVaultRefs(entity)).rejects.toThrow( + "not enabled for this project/environment", + ); + expect(mockFetch).not.toHaveBeenCalled(); + }, + ); + + it("rejects a ref restricted to another environment from the environment env", async () => { + findMany.mockResolvedValue([ + { + name: "prod-only", + providerType: "doppler", + config: { providerType: "doppler", serviceToken: "dp.st.token" }, + assignments: [ + { projectId: "proj-1", environmentIds: ["production-env"] }, + ], + }, + ]); + + await expect( + withResolvedVaultRefs({ + env: null, + environment: { + environmentId: "dev-env", + env: "SECRET=${{vault.prod-only.X}}", + project: { projectId: "proj-1", env: null, organizationId: "org-1" }, + }, + }), + ).rejects.toThrow("not enabled for this project/environment"); + expect(mockFetch).not.toHaveBeenCalled(); + }); + it("prepareEnvironmentVariables throws on unresolved vault refs", () => { expect(() => prepareEnvironmentVariables("FOO=${{vault.prod.SECRET}}", "", ""), diff --git a/apps/dokploy/components/dashboard/application/environment/show-environment.tsx b/apps/dokploy/components/dashboard/application/environment/show-environment.tsx index efd78fe69..b4bfff617 100644 --- a/apps/dokploy/components/dashboard/application/environment/show-environment.tsx +++ b/apps/dokploy/components/dashboard/application/environment/show-environment.tsx @@ -62,6 +62,8 @@ export const ShowEnvironment = ({ id, type }: Props) => { const completionSource = useEnvCompletionSource({ projectEnv: data?.environment?.project?.env, environmentEnv: data?.environment?.env, + projectId: data?.environment?.projectId, + environmentId: data?.environment?.environmentId, }); const mutationMap = { diff --git a/apps/dokploy/components/dashboard/application/environment/show.tsx b/apps/dokploy/components/dashboard/application/environment/show.tsx index 05f4fd491..927c8f81b 100644 --- a/apps/dokploy/components/dashboard/application/environment/show.tsx +++ b/apps/dokploy/components/dashboard/application/environment/show.tsx @@ -49,6 +49,8 @@ export const ShowEnvironment = ({ applicationId }: Props) => { const completionSource = useEnvCompletionSource({ projectEnv: data?.environment?.project?.env, environmentEnv: data?.environment?.env, + projectId: data?.environment?.projectId, + environmentId: data?.environment?.environmentId, }); const form = useForm({ diff --git a/apps/dokploy/components/dashboard/project/environment-variables.tsx b/apps/dokploy/components/dashboard/project/environment-variables.tsx index 3e56ab217..f6358dcb8 100644 --- a/apps/dokploy/components/dashboard/project/environment-variables.tsx +++ b/apps/dokploy/components/dashboard/project/environment-variables.tsx @@ -58,6 +58,8 @@ export const EnvironmentVariables = ({ environmentId, children }: Props) => { const completionSource = useEnvCompletionSource({ includeShared: false, + projectId: data?.projectId, + environmentId, }); const form = useForm({ defaultValues: { diff --git a/apps/dokploy/components/dashboard/projects/project-environment.tsx b/apps/dokploy/components/dashboard/projects/project-environment.tsx index aed5134c2..156089524 100644 --- a/apps/dokploy/components/dashboard/projects/project-environment.tsx +++ b/apps/dokploy/components/dashboard/projects/project-environment.tsx @@ -58,6 +58,7 @@ export const ProjectEnvironment = ({ projectId, children }: Props) => { const completionSource = useEnvCompletionSource({ includeShared: false, + projectId, }); const form = useForm({ defaultValues: { diff --git a/apps/dokploy/components/dashboard/settings/vault/handle-vault-provider.tsx b/apps/dokploy/components/dashboard/settings/vault/handle-vault-provider.tsx index 925fdc1d4..32e2e75ef 100644 --- a/apps/dokploy/components/dashboard/settings/vault/handle-vault-provider.tsx +++ b/apps/dokploy/components/dashboard/settings/vault/handle-vault-provider.tsx @@ -7,6 +7,7 @@ import { z } from "zod"; import { vaultProviderIcons } from "@/components/icons/vault-provider-icons"; import { AlertBlock } from "@/components/shared/alert-block"; import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; import { Dialog, DialogContent, @@ -76,6 +77,12 @@ const VaultProviderSchema = z azureClientSecret: z.string(), dopplerProject: z.string(), dopplerConfig: z.string(), + assignments: z.array( + z.object({ + projectId: z.string(), + environmentIds: z.array(z.string()), + }), + ), }) .superRefine((data, ctx) => { const isValidUrl = (value: string) => { @@ -215,6 +222,7 @@ const defaultValues: VaultProviderForm = { azureClientSecret: "", dopplerProject: "", dopplerConfig: "", + assignments: [], }; const buildConfig = (data: VaultProviderForm) => { @@ -303,6 +311,39 @@ export const HandleVaultProvider = ({ vaultProviderId }: Props) => { }); const providerType = form.watch("providerType"); + const assignments = form.watch("assignments"); + const { data: orgProjects } = api.project.all.useQuery(); + + const setAssignments = ( + next: { projectId: string; environmentIds: string[] }[], + ) => form.setValue("assignments", next, { shouldValidate: true }); + + const toggleProject = (targetProjectId: string) => { + const exists = assignments.some((a) => a.projectId === targetProjectId); + setAssignments( + exists + ? assignments.filter((a) => a.projectId !== targetProjectId) + : [...assignments, { projectId: targetProjectId, environmentIds: [] }], + ); + }; + + const toggleEnvironment = ( + targetProjectId: string, + environmentId: string, + ) => { + setAssignments( + assignments.map((a) => { + if (a.projectId !== targetProjectId) return a; + const has = a.environmentIds.includes(environmentId); + return { + ...a, + environmentIds: has + ? a.environmentIds.filter((e) => e !== environmentId) + : [...a.environmentIds, environmentId], + }; + }), + ); + }; useEffect(() => { if (provider) { @@ -310,6 +351,7 @@ export const HandleVaultProvider = ({ vaultProviderId }: Props) => { ...defaultValues, name: provider.name, providerType: provider.config.providerType, + assignments: provider.assignments ?? [], ...(provider.config.providerType === "hashicorp" && { url: provider.config.url, token: provider.config.token, @@ -351,6 +393,7 @@ export const HandleVaultProvider = ({ vaultProviderId }: Props) => { const payload: any = { name: data.name, config: buildConfig(data), + assignments: data.assignments, ...(vaultProviderId && { vaultProviderId }), }; await mutateAsync(payload) @@ -751,8 +794,8 @@ export const HandleVaultProvider = ({ vaultProviderId }: Props) => { /> - App Registration with the "Key Vault Secrets User" role on - the vault. Reference format:{" "} + App Registration with the "Key Vault Secrets User" role on the + vault. Reference format:{" "} {"${{vault..secret-name}}"} @@ -816,6 +859,71 @@ export const HandleVaultProvider = ({ vaultProviderId }: Props) => { )} +
+ Access + + This provider can only be referenced from the selected projects. + Pick environments to narrow it further — none selected means all + environments of that project. + +
+ {orgProjects?.map((project) => { + const assignment = assignments.find( + (a) => a.projectId === project.projectId, + ); + return ( +
+ + {assignment && ( +
+ {project.environments?.map((environment) => ( + + ))} + {assignment.environmentIds.length === 0 && ( + + All environments + + )} +
+ )} +
+ ); + })} +
+ {form.formState.errors.assignments && ( +

+ {form.formState.errors.assignments.message} +

+ )} +
+