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:
Mauricio Siu
2026-08-10 16:25:39 -06:00
parent b8822a1fde
commit f6622428f7
17 changed files with 9427 additions and 35 deletions
@@ -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> => {