feat(vault): add Scaleway Secret Manager provider

Adds Scaleway Secret Manager as a first-class vault provider alongside
HashiCorp Vault/OpenBao, Infisical, AWS, Doppler and Azure Key Vault.

- New `scaleway` provider type, zod-validated config (region, project ID,
  API secret key, overridable API URL) and migration adding the enum value
- Plain `fetch` client (no new dependency) against the Secret Manager
  v1beta1 REST API: access-by-path for reads, paginated listing for the
  env editor autocomplete, and a connection test
- Refs are `[folder/]name[:field]` — the field selector extracts a key
  from JSON/key-value secrets, like the AWS provider
- Secret key masked in API responses like every other provider credential
- Settings -> Secrets form with region picker, brand icon and reference
  format help
This commit is contained in:
Quentin Gillet
2026-08-11 16:21:32 +01:00
parent 9919e369d1
commit f238347ed7
12 changed files with 9558 additions and 2 deletions
@@ -42,6 +42,7 @@ const providerLabels = {
aws: "AWS Secrets Manager",
doppler: "Doppler",
azure: "Azure Key Vault",
scaleway: "Scaleway Secret Manager",
} as const;
type ProviderType = keyof typeof providerLabels;
@@ -55,7 +56,14 @@ const VaultProviderSchema = z
message:
"Only letters, numbers, dashes and underscores (used in ${{vault.<name>.<secret>}})",
}),
providerType: z.enum(["hashicorp", "infisical", "aws", "doppler", "azure"]),
providerType: z.enum([
"hashicorp",
"infisical",
"aws",
"doppler",
"azure",
"scaleway",
]),
url: z.string(),
token: z.string(),
namespace: z.string(),
@@ -77,6 +85,10 @@ const VaultProviderSchema = z
azureClientSecret: z.string(),
dopplerProject: z.string(),
dopplerConfig: z.string(),
scalewayRegion: z.string(),
scalewayProjectId: z.string(),
scalewaySecretKey: z.string(),
scalewayApiUrl: z.string(),
assignments: z.array(
z.object({
projectId: z.string(),
@@ -127,6 +139,17 @@ const VaultProviderSchema = z
path: ["awsEndpoint"],
});
}
if (
data.providerType === "scaleway" &&
data.scalewayApiUrl &&
!isValidUrl(data.scalewayApiUrl)
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Enter a valid URL (e.g. https://api.scaleway.com)",
path: ["scalewayApiUrl"],
});
}
if (
data.providerType === "infisical" &&
data.siteUrl &&
@@ -166,6 +189,11 @@ const VaultProviderSchema = z
["azureClientId", "Client ID is required"],
["azureClientSecret", "Client Secret is required"],
],
scaleway: [
["scalewayRegion", "Region is required"],
["scalewayProjectId", "Project ID is required"],
["scalewaySecretKey", "Secret Key is required"],
],
};
for (const [field, message] of required[data.providerType] ?? []) {
@@ -222,6 +250,10 @@ const defaultValues: VaultProviderForm = {
azureClientSecret: "",
dopplerProject: "",
dopplerConfig: "",
scalewayRegion: "fr-par",
scalewayProjectId: "",
scalewaySecretKey: "",
scalewayApiUrl: "https://api.scaleway.com",
assignments: [],
};
@@ -268,6 +300,14 @@ const buildConfig = (data: VaultProviderForm) => {
clientId: data.azureClientId,
clientSecret: data.azureClientSecret,
};
case "scaleway":
return {
providerType: "scaleway" as const,
region: data.scalewayRegion || "fr-par",
projectId: data.scalewayProjectId,
secretKey: data.scalewaySecretKey,
apiUrl: data.scalewayApiUrl || "https://api.scaleway.com",
};
}
};
@@ -383,6 +423,12 @@ export const HandleVaultProvider = ({ vaultProviderId }: Props) => {
azureClientId: provider.config.clientId,
azureClientSecret: provider.config.clientSecret,
}),
...(provider.config.providerType === "scaleway" && {
scalewayRegion: provider.config.region,
scalewayProjectId: provider.config.projectId,
scalewaySecretKey: provider.config.secretKey,
scalewayApiUrl: provider.config.apiUrl,
}),
});
} else if (!vaultProviderId) {
form.reset(defaultValues);
@@ -859,6 +905,97 @@ export const HandleVaultProvider = ({ vaultProviderId }: Props) => {
</>
)}
{providerType === "scaleway" && (
<>
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="scalewayRegion"
render={({ field }) => (
<FormItem>
<FormLabel>Region</FormLabel>
<Select
onValueChange={field.onChange}
value={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="fr-par" />
</SelectTrigger>
</FormControl>
<SelectContent>
{["fr-par", "nl-ams", "pl-waw"].map((region) => (
<SelectItem key={region} value={region}>
{region}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="scalewayProjectId"
render={({ field }) => (
<FormItem>
<FormLabel>Project ID</FormLabel>
<FormControl>
<Input
placeholder="00000000-0000-0000-0000-000000000000"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="scalewaySecretKey"
render={({ field }) => (
<FormItem>
<FormLabel>Secret Key</FormLabel>
<FormControl>
<Input type="password" {...field} />
</FormControl>
<FormDescription>
The secret key of an API key with the{" "}
<code>SecretManagerReadOnly</code> permission set
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="scalewayApiUrl"
render={({ field }) => (
<FormItem>
<FormLabel>API URL</FormLabel>
<FormControl>
<Input
placeholder="https://api.scaleway.com"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormDescription>
Reference format:{" "}
<code>{"${{vault.<name>.secret-name}}"}</code>,{" "}
<code>{"${{vault.<name>.folder/secret-name}}"}</code> for
secrets in a path, or{" "}
<code>{"${{vault.<name>.secret-name:field}}"}</code> for JSON
secrets
</FormDescription>
</>
)}
<div className="flex flex-col gap-2 rounded-lg border p-3">
<FormLabel>Access</FormLabel>
<FormDescription>
@@ -20,6 +20,7 @@ const providerLabels: Record<string, string> = {
aws: "AWS Secrets Manager",
doppler: "Doppler",
azure: "Azure Key Vault",
scaleway: "Scaleway Secret Manager",
};
export const ShowVaultProviders = () => {
@@ -571,10 +571,22 @@ export const AzureIcon = ({ className }: Props) => (
</svg>
);
export const ScalewayIcon = ({ className }: Props) => (
<svg
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<path d="M16.605 11.11v5.72a1.77 1.77 0 01-1.54 1.69h-4a1.43 1.43 0 01-1.31-1.22 1.09 1.09 0 010-.18 1.37 1.37 0 011.37-1.36h1.74a1 1 0 001-1v-3.62a1.4 1.4 0 011.18-1.39h.17a1.37 1.37 0 011.39 1.36zm-6.46 1.74V9.26a1 1 0 011-1h1.85a1.37 1.37 0 001.37-1.37 1 1 0 000-.17 1.45 1.45 0 00-1.41-1.2h-3.96a1.81 1.81 0 00-1.58 1.66v5.7a1.37 1.37 0 001.37 1.37h.21a1.4 1.4 0 001.15-1.4zm12-4.29V20a4.53 4.53 0 01-4.15 4h-7.58a8.57 8.57 0 01-8.56-8.57V4.54A4.54 4.54 0 016.395 0h7.18a8.56 8.56 0 018.56 8.56zm-2.74 0a5.83 5.83 0 00-5.82-5.82h-7.19a1.79 1.79 0 00-1.8 1.8v10.89a5.83 5.83 0 005.82 5.8h7.44a1.79 1.79 0 001.54-1.48z" />
</svg>
);
export const vaultProviderIcons = {
hashicorp: HashicorpVaultIcon,
infisical: InfisicalIcon,
aws: AwsIcon,
doppler: DopplerIcon,
azure: AzureIcon,
scaleway: ScalewayIcon,
} as const;
@@ -173,7 +173,7 @@ const RESOURCE_META: Record<string, { label: string; description: string }> = {
vaultProvider: {
label: "Secrets Providers",
description:
"Manage external secret managers (HashiCorp Vault, AWS, Azure, Infisical, Doppler) and where their secrets can be referenced",
"Manage external secret managers (HashiCorp Vault, AWS, Azure, Infisical, Doppler, Scaleway) and where their secrets can be referenced",
},
};