mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-10-15 21:31:11 +00:00
feat: enhance vault provider management and environment variable resolution
- Introduced support for project and environment assignments for vault providers, allowing for more granular access control. - Updated the vault provider schema to include assignments and modified related components to handle these changes. - Enhanced UI to display assignment status and allow users to manage project and environment associations effectively. - Added validation to ensure assignments reference valid projects and environments within the organization. - Updated tests to cover new functionality related to vault provider assignments and environment variable resolution.
This commit is contained in:
+133
-7
@@ -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}}", "", ""),
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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<EnvironmentSchema>({
|
||||
|
||||
@@ -58,6 +58,8 @@ export const EnvironmentVariables = ({ environmentId, children }: Props) => {
|
||||
|
||||
const completionSource = useEnvCompletionSource({
|
||||
includeShared: false,
|
||||
projectId: data?.projectId,
|
||||
environmentId,
|
||||
});
|
||||
const form = useForm<UpdateEnvironment>({
|
||||
defaultValues: {
|
||||
|
||||
@@ -58,6 +58,7 @@ export const ProjectEnvironment = ({ projectId, children }: Props) => {
|
||||
|
||||
const completionSource = useEnvCompletionSource({
|
||||
includeShared: false,
|
||||
projectId,
|
||||
});
|
||||
const form = useForm<UpdateProject>({
|
||||
defaultValues: {
|
||||
|
||||
@@ -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) => {
|
||||
/>
|
||||
</div>
|
||||
<FormDescription>
|
||||
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:{" "}
|
||||
<code>{"${{vault.<name>.secret-name}}"}</code>
|
||||
</FormDescription>
|
||||
</>
|
||||
@@ -816,6 +859,71 @@ export const HandleVaultProvider = ({ vaultProviderId }: Props) => {
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-2 rounded-lg border p-3">
|
||||
<FormLabel>Access</FormLabel>
|
||||
<FormDescription>
|
||||
This provider can only be referenced from the selected projects.
|
||||
Pick environments to narrow it further — none selected means all
|
||||
environments of that project.
|
||||
</FormDescription>
|
||||
<div className="flex flex-col gap-2">
|
||||
{orgProjects?.map((project) => {
|
||||
const assignment = assignments.find(
|
||||
(a) => a.projectId === project.projectId,
|
||||
);
|
||||
return (
|
||||
<div
|
||||
key={project.projectId}
|
||||
className="flex flex-col gap-1.5"
|
||||
>
|
||||
<label className="flex flex-row items-center gap-2 text-sm cursor-pointer">
|
||||
<Checkbox
|
||||
checked={!!assignment}
|
||||
onCheckedChange={() =>
|
||||
toggleProject(project.projectId)
|
||||
}
|
||||
/>
|
||||
{project.name}
|
||||
</label>
|
||||
{assignment && (
|
||||
<div className="flex flex-row flex-wrap gap-3 pl-6">
|
||||
{project.environments?.map((environment) => (
|
||||
<label
|
||||
key={environment.environmentId}
|
||||
className="flex flex-row items-center gap-1.5 text-xs text-muted-foreground cursor-pointer"
|
||||
>
|
||||
<Checkbox
|
||||
checked={assignment.environmentIds.includes(
|
||||
environment.environmentId,
|
||||
)}
|
||||
onCheckedChange={() =>
|
||||
toggleEnvironment(
|
||||
project.projectId,
|
||||
environment.environmentId,
|
||||
)
|
||||
}
|
||||
/>
|
||||
{environment.name}
|
||||
</label>
|
||||
))}
|
||||
{assignment.environmentIds.length === 0 && (
|
||||
<span className="text-xs text-muted-foreground italic self-center">
|
||||
All environments
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{form.formState.errors.assignments && (
|
||||
<p className="text-sm font-medium text-destructive">
|
||||
{form.formState.errors.assignments.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="flex w-full flex-row justify-between gap-2 sm:justify-between">
|
||||
<Button
|
||||
type="button"
|
||||
|
||||
@@ -80,6 +80,18 @@ export const ShowVaultProviders = () => {
|
||||
{provider.name}
|
||||
</span>
|
||||
<div className="flex flex-row gap-2 items-center">
|
||||
{provider.assignments.length === 0 ? (
|
||||
<Badge variant="destructive">
|
||||
Not assigned
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary">
|
||||
{provider.assignments.length}{" "}
|
||||
{provider.assignments.length === 1
|
||||
? "project"
|
||||
: "projects"}
|
||||
</Badge>
|
||||
)}
|
||||
<Badge variant="outline">
|
||||
{providerLabels[provider.providerType] ??
|
||||
provider.providerType}
|
||||
|
||||
@@ -163,6 +163,11 @@ const RESOURCE_META: Record<string, { label: string; description: string }> = {
|
||||
label: "Audit Logs",
|
||||
description: "View the audit log of actions performed in the organization",
|
||||
},
|
||||
vaultProvider: {
|
||||
label: "Secrets Providers",
|
||||
description:
|
||||
"Manage external secret managers (HashiCorp Vault, AWS, Azure, Infisical, Doppler) and where their secrets can be referenced",
|
||||
},
|
||||
};
|
||||
|
||||
/** Descriptions for each action within a resource */
|
||||
@@ -419,6 +424,22 @@ const ACTION_META: Record<
|
||||
auditLog: {
|
||||
read: { label: "Read", description: "View the audit log history" },
|
||||
},
|
||||
vaultProvider: {
|
||||
read: {
|
||||
label: "Read",
|
||||
description: "View providers and secret names for env autocomplete",
|
||||
},
|
||||
create: {
|
||||
label: "Create",
|
||||
description: "Connect new secret providers and test their connection",
|
||||
},
|
||||
update: {
|
||||
label: "Update",
|
||||
description:
|
||||
"Edit provider credentials and project/environment assignments",
|
||||
},
|
||||
delete: { label: "Delete", description: "Remove secret providers" },
|
||||
},
|
||||
};
|
||||
|
||||
/** Resources that should be hidden from the custom role editor (better-auth internals) */
|
||||
|
||||
@@ -12,6 +12,8 @@ interface Options {
|
||||
projectEnv?: string | null;
|
||||
environmentEnv?: string | null;
|
||||
includeShared?: boolean;
|
||||
projectId?: string;
|
||||
environmentId?: string;
|
||||
}
|
||||
|
||||
const parseKeys = (env?: string | null) =>
|
||||
@@ -55,9 +57,22 @@ export const useEnvCompletionSource = ({
|
||||
projectEnv,
|
||||
environmentEnv,
|
||||
includeShared = true,
|
||||
projectId,
|
||||
environmentId,
|
||||
}: Options = {}) => {
|
||||
const { data: providers } = api.vaultProvider.all.useQuery();
|
||||
const { data: allProviders } = api.vaultProvider.all.useQuery();
|
||||
const utils = api.useUtils();
|
||||
const providers = projectId
|
||||
? allProviders?.filter((provider) =>
|
||||
provider.assignments?.some(
|
||||
(assignment) =>
|
||||
assignment.projectId === projectId &&
|
||||
(assignment.environmentIds.length === 0 ||
|
||||
!environmentId ||
|
||||
assignment.environmentIds.includes(environmentId)),
|
||||
),
|
||||
)
|
||||
: [];
|
||||
|
||||
return useCallback(
|
||||
async (context: CompletionContext): Promise<CompletionResult | null> => {
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
CREATE TYPE "public"."VaultProviderType" AS ENUM('hashicorp', 'infisical', 'aws', 'doppler', 'azure');--> statement-breakpoint
|
||||
CREATE TABLE "vault_provider" (
|
||||
"vaultProviderId" text PRIMARY KEY NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"providerType" "VaultProviderType" NOT NULL,
|
||||
"config" jsonb NOT NULL,
|
||||
"assignments" jsonb DEFAULT '[]'::jsonb NOT NULL,
|
||||
"organizationId" text NOT NULL,
|
||||
"createdAt" text NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "vault_provider" ADD CONSTRAINT "vault_provider_organizationId_organization_id_fk" FOREIGN KEY ("organizationId") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "vault_provider_org_name_idx" ON "vault_provider" USING btree ("organizationId","name");
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1268,6 +1268,13 @@
|
||||
"when": 1786132623336,
|
||||
"tag": "0180_slippery_norrin_radd",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 181,
|
||||
"version": "7",
|
||||
"when": 1786400712839,
|
||||
"tag": "0181_dashing_havok",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import {
|
||||
createVaultProvider,
|
||||
findVaultProviderById,
|
||||
findVaultProviderInOrganization,
|
||||
findVaultProvidersByOrganizationId,
|
||||
listVaultProviderSecretNames,
|
||||
maskVaultProviderConfig,
|
||||
@@ -21,20 +21,6 @@ import {
|
||||
} from "@/server/db/schema";
|
||||
import { createTRPCRouter, withPermission } from "../trpc";
|
||||
|
||||
const findInOrganization = async (
|
||||
vaultProviderId: string,
|
||||
organizationId: string,
|
||||
) => {
|
||||
const provider = await findVaultProviderById(vaultProviderId);
|
||||
if (provider.organizationId !== organizationId) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You are not allowed to access this vault provider",
|
||||
});
|
||||
}
|
||||
return provider;
|
||||
};
|
||||
|
||||
export const vaultProviderRouter = createTRPCRouter({
|
||||
create: withPermission("vaultProvider", "create")
|
||||
.input(apiCreateVaultProvider)
|
||||
@@ -55,7 +41,7 @@ export const vaultProviderRouter = createTRPCRouter({
|
||||
update: withPermission("vaultProvider", "update")
|
||||
.input(apiUpdateVaultProvider)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await findInOrganization(
|
||||
await findVaultProviderInOrganization(
|
||||
input.vaultProviderId,
|
||||
ctx.session.activeOrganizationId,
|
||||
);
|
||||
@@ -63,6 +49,7 @@ export const vaultProviderRouter = createTRPCRouter({
|
||||
input.vaultProviderId,
|
||||
input.name,
|
||||
input.config,
|
||||
input.assignments,
|
||||
);
|
||||
await audit(ctx, {
|
||||
action: "update",
|
||||
@@ -76,7 +63,7 @@ export const vaultProviderRouter = createTRPCRouter({
|
||||
remove: withPermission("vaultProvider", "delete")
|
||||
.input(apiRemoveVaultProvider)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const provider = await findInOrganization(
|
||||
const provider = await findVaultProviderInOrganization(
|
||||
input.vaultProviderId,
|
||||
ctx.session.activeOrganizationId,
|
||||
);
|
||||
@@ -103,7 +90,7 @@ export const vaultProviderRouter = createTRPCRouter({
|
||||
one: withPermission("vaultProvider", "read")
|
||||
.input(apiFindOneVaultProvider)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const provider = await findInOrganization(
|
||||
const provider = await findVaultProviderInOrganization(
|
||||
input.vaultProviderId,
|
||||
ctx.session.activeOrganizationId,
|
||||
);
|
||||
@@ -122,7 +109,7 @@ export const vaultProviderRouter = createTRPCRouter({
|
||||
|
||||
let config = input.config;
|
||||
if (input.vaultProviderId) {
|
||||
const provider = await findInOrganization(
|
||||
const provider = await findVaultProviderInOrganization(
|
||||
input.vaultProviderId,
|
||||
ctx.session.activeOrganizationId,
|
||||
);
|
||||
@@ -138,7 +125,7 @@ export const vaultProviderRouter = createTRPCRouter({
|
||||
listSecretNames: withPermission("vaultProvider", "read")
|
||||
.input(apiListVaultSecretNames)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const provider = await findInOrganization(
|
||||
const provider = await findVaultProviderInOrganization(
|
||||
input.vaultProviderId,
|
||||
ctx.session.activeOrganizationId,
|
||||
);
|
||||
|
||||
@@ -63,6 +63,15 @@ export const vaultProviderConfigSchema = z.discriminatedUnion("providerType", [
|
||||
|
||||
export type VaultProviderConfig = z.infer<typeof vaultProviderConfigSchema>;
|
||||
|
||||
export const vaultProviderAssignmentSchema = z.object({
|
||||
projectId: z.string().min(1),
|
||||
environmentIds: z.array(z.string().min(1)).default([]),
|
||||
});
|
||||
|
||||
export type VaultProviderAssignment = z.infer<
|
||||
typeof vaultProviderAssignmentSchema
|
||||
>;
|
||||
|
||||
export const vaultProvider = pgTable(
|
||||
"vault_provider",
|
||||
{
|
||||
@@ -73,6 +82,10 @@ export const vaultProvider = pgTable(
|
||||
name: text("name").notNull(),
|
||||
providerType: vaultProviderType("providerType").notNull(),
|
||||
config: jsonb("config").$type<VaultProviderConfig>().notNull(),
|
||||
assignments: jsonb("assignments")
|
||||
.$type<VaultProviderAssignment[]>()
|
||||
.notNull()
|
||||
.default([]),
|
||||
organizationId: text("organizationId")
|
||||
.notNull()
|
||||
.references(() => organization.id, { onDelete: "cascade" }),
|
||||
@@ -102,12 +115,14 @@ const createSchema = createInsertSchema(vaultProvider);
|
||||
export const apiCreateVaultProvider = createSchema.pick({}).extend({
|
||||
name: vaultProviderNameSchema,
|
||||
config: vaultProviderConfigSchema,
|
||||
assignments: z.array(vaultProviderAssignmentSchema),
|
||||
});
|
||||
|
||||
export const apiUpdateVaultProvider = createSchema.pick({}).extend({
|
||||
vaultProviderId: z.string().min(1),
|
||||
name: vaultProviderNameSchema,
|
||||
config: vaultProviderConfigSchema,
|
||||
assignments: z.array(vaultProviderAssignmentSchema),
|
||||
});
|
||||
|
||||
export const apiFindOneVaultProvider = z.object({
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { db } from "@dokploy/server/db";
|
||||
import {
|
||||
type apiCreateVaultProvider,
|
||||
projects,
|
||||
type VaultProviderAssignment,
|
||||
type VaultProviderConfig,
|
||||
vaultProvider,
|
||||
} from "@dokploy/server/db/schema";
|
||||
@@ -58,10 +60,44 @@ const isUniqueNameViolation = (error: unknown) =>
|
||||
error instanceof Error &&
|
||||
error.message.includes("vault_provider_org_name_idx");
|
||||
|
||||
const validateAssignments = async (
|
||||
assignments: VaultProviderAssignment[],
|
||||
organizationId: string,
|
||||
) => {
|
||||
const orgProjects = await db.query.projects.findMany({
|
||||
where: eq(projects.organizationId, organizationId),
|
||||
with: { environments: true },
|
||||
});
|
||||
for (const assignment of assignments) {
|
||||
const project = orgProjects.find(
|
||||
(p) => p.projectId === assignment.projectId,
|
||||
);
|
||||
if (!project) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Assignment references a project outside this organization",
|
||||
});
|
||||
}
|
||||
const environmentIds = new Set(
|
||||
project.environments.map((e) => e.environmentId),
|
||||
);
|
||||
for (const environmentId of assignment.environmentIds) {
|
||||
if (!environmentIds.has(environmentId)) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message:
|
||||
"Assignment references an environment outside the selected project",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const createVaultProvider = async (
|
||||
input: z.infer<typeof apiCreateVaultProvider>,
|
||||
organizationId: string,
|
||||
) => {
|
||||
await validateAssignments(input.assignments, organizationId);
|
||||
try {
|
||||
const newProvider = await db
|
||||
.insert(vaultProvider)
|
||||
@@ -69,6 +105,7 @@ export const createVaultProvider = async (
|
||||
name: input.name,
|
||||
providerType: input.config.providerType,
|
||||
config: input.config,
|
||||
assignments: input.assignments,
|
||||
organizationId,
|
||||
})
|
||||
.returning()
|
||||
@@ -105,6 +142,20 @@ export const findVaultProviderById = async (vaultProviderId: string) => {
|
||||
return provider;
|
||||
};
|
||||
|
||||
export const findVaultProviderInOrganization = async (
|
||||
vaultProviderId: string,
|
||||
organizationId: string,
|
||||
) => {
|
||||
const provider = await findVaultProviderById(vaultProviderId);
|
||||
if (provider.organizationId !== organizationId) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You are not allowed to access this vault provider",
|
||||
});
|
||||
}
|
||||
return provider;
|
||||
};
|
||||
|
||||
export const findVaultProvidersByOrganizationId = async (
|
||||
organizationId: string,
|
||||
) => {
|
||||
@@ -118,9 +169,11 @@ export const updateVaultProvider = async (
|
||||
vaultProviderId: string,
|
||||
name: string,
|
||||
config: VaultProviderConfig,
|
||||
assignments: VaultProviderAssignment[],
|
||||
) => {
|
||||
const existing = await findVaultProviderById(vaultProviderId);
|
||||
const mergedConfig = mergeVaultProviderConfig(config, existing.config);
|
||||
await validateAssignments(assignments, existing.organizationId);
|
||||
|
||||
try {
|
||||
const updated = await db
|
||||
@@ -129,6 +182,7 @@ export const updateVaultProvider = async (
|
||||
name,
|
||||
providerType: mergedConfig.providerType,
|
||||
config: mergedConfig,
|
||||
assignments,
|
||||
})
|
||||
.where(eq(vaultProvider.vaultProviderId, vaultProviderId))
|
||||
.returning()
|
||||
|
||||
@@ -42,11 +42,7 @@ const getAccessToken = async (config: AzureConfig) => {
|
||||
return data.access_token;
|
||||
};
|
||||
|
||||
const readSecret = async (
|
||||
config: AzureConfig,
|
||||
token: string,
|
||||
name: string,
|
||||
) => {
|
||||
const readSecret = async (config: AzureConfig, token: string, name: string) => {
|
||||
const response = await vaultFetch(
|
||||
`${baseUrl(config)}/secrets/${encodeURIComponent(name)}?api-version=${API_VERSION}`,
|
||||
{ headers: { Authorization: `Bearer ${token}` } },
|
||||
|
||||
Binary file not shown.
Reference in New Issue
Block a user