Merge pull request #5046 from Quentin-Gillet/feat/scaleway-secret-manager

feat(vault): add Scaleway Secret Manager provider
This commit is contained in:
Mauricio Siu
2026-08-11 20:15:28 -06:00
committed by GitHub
12 changed files with 9565 additions and 2 deletions
+176
View File
@@ -20,6 +20,7 @@ import {
import { azureClient } from "@dokploy/server/utils/vault/azure";
import { dopplerClient } from "@dokploy/server/utils/vault/doppler";
import { hashicorpClient } from "@dokploy/server/utils/vault/hashicorp";
import { scalewayClient } from "@dokploy/server/utils/vault/scaleway";
const mockFetch = vi.fn();
global.fetch = mockFetch as typeof fetch;
@@ -440,3 +441,178 @@ describe("doppler client", () => {
).rejects.toThrow("status 401");
});
});
describe("scaleway client", () => {
const config = {
providerType: "scaleway" as const,
region: "fr-par",
projectId: "project-1",
secretKey: "scw-secret-key",
apiUrl: "https://api.scaleway.com",
};
const accessResponse = (value: string) =>
jsonResponse({
secret_id: "secret-1",
revision: 1,
data: Buffer.from(value).toString("base64"),
});
it("reads a secret by name from the root path and decodes the payload", async () => {
mockFetch.mockResolvedValue(accessResponse("postgres://real"));
const result = await scalewayClient.getSecrets(config, ["db-url"]);
expect(result).toEqual({ "db-url": "postgres://real" });
expect(mockFetch).toHaveBeenCalledTimes(1);
const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit];
expect(url).toBe(
"https://api.scaleway.com/secret-manager/v1beta1/regions/fr-par/secrets-by-path/versions/latest_enabled/access?project_id=project-1&secret_name=db-url&secret_path=%2F",
);
expect((init.headers as Record<string, string>)["X-Auth-Token"]).toBe(
"scw-secret-key",
);
});
it("sends the folder of a path-qualified ref as secret_path", async () => {
mockFetch.mockResolvedValue(accessResponse("value"));
await scalewayClient.getSecrets(config, ["prod/db/password"]);
expect(mockFetch.mock.calls[0]?.[0]).toContain(
"secret_name=password&secret_path=%2Fprod%2Fdb",
);
});
it("extracts a field from a JSON secret and fetches the secret once", async () => {
mockFetch.mockResolvedValue(
accessResponse(JSON.stringify({ user: "admin", port: 5432 })),
);
const result = await scalewayClient.getSecrets(config, [
"db-creds:user",
"db-creds:port",
]);
expect(result).toEqual({
"db-creds:user": "admin",
"db-creds:port": "5432",
});
expect(mockFetch).toHaveBeenCalledTimes(1);
});
it("throws when the secret is not JSON but a field is requested", async () => {
mockFetch.mockResolvedValue(accessResponse("plain-value"));
await expect(
scalewayClient.getSecrets(config, ["db-creds:user"]),
).rejects.toThrow("is not JSON");
});
it("throws when the requested field is missing", async () => {
mockFetch.mockResolvedValue(accessResponse(JSON.stringify({ user: "a" })));
await expect(
scalewayClient.getSecrets(config, ["db-creds:password"]),
).rejects.toThrow('field "password" not found');
});
it("throws a clear error for a missing secret", async () => {
mockFetch.mockResolvedValue(
jsonResponse({ message: "not found" }, false, 404),
);
await expect(scalewayClient.getSecrets(config, ["nope"])).rejects.toThrow(
'secret "nope" not found in path "/"',
);
});
it("reports authentication failures with the API message", async () => {
mockFetch.mockResolvedValue(
jsonResponse({ message: "denied authentication" }, false, 403),
);
await expect(scalewayClient.getSecrets(config, ["db-url"])).rejects.toThrow(
"authentication failed (status 403: denied authentication)",
);
});
it("throws when the secret has no enabled version", async () => {
mockFetch.mockResolvedValue(jsonResponse({ secret_id: "secret-1" }));
await expect(scalewayClient.getSecrets(config, ["db-url"])).rejects.toThrow(
"has no enabled version",
);
});
it("rejects a ref without a secret name", async () => {
await expect(scalewayClient.getSecrets(config, ["prod/"])).rejects.toThrow(
"expected format <name> or <folder>/<name>[:field]",
);
});
it("tests the connection against the secrets listing", async () => {
mockFetch.mockResolvedValue(jsonResponse({ secrets: [], total_count: 0 }));
await scalewayClient.testConnection(config);
expect(mockFetch.mock.calls[0]?.[0]).toBe(
"https://api.scaleway.com/secret-manager/v1beta1/regions/fr-par/secrets?project_id=project-1&page_size=1",
);
});
it("lists secret names with their folder prefix", async () => {
mockFetch.mockResolvedValue(
jsonResponse({
secrets: [
{ id: "1", name: "db-url", path: "/" },
{ id: "2", name: "password", path: "/prod/db" },
],
total_count: 2,
}),
);
const names = await scalewayClient.listSecretNames?.(config);
expect(names).toEqual(["db-url", "prod/db/password"]);
expect(mockFetch).toHaveBeenCalledTimes(1);
});
it("follows pagination until a short page is returned", async () => {
const page = (start: number, size: number) =>
jsonResponse({
secrets: Array.from({ length: size }, (_, index) => ({
id: String(start + index),
name: `secret-${start + index}`,
path: "/",
})),
});
mockFetch.mockImplementation(async (url: string) =>
url.includes("page=2") ? page(101, 2) : page(1, 100),
);
const names = await scalewayClient.listSecretNames?.(config);
expect(names).toHaveLength(102);
expect(mockFetch).toHaveBeenCalledTimes(2);
});
it("resolves env refs end to end through a scaleway provider", async () => {
findMany.mockResolvedValue([
{
name: "scw-prod",
providerType: "scaleway",
config,
assignments: assignedEverywhere,
},
]);
mockFetch.mockResolvedValue(accessResponse("s3cret"));
const result = await resolveVaultReferences(
"DB_PASSWORD=${{vault.scw-prod.prod/db-password}}",
scope,
);
expect(result).toBe("DB_PASSWORD=s3cret");
});
});
@@ -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",
},
};
@@ -0,0 +1 @@
ALTER TYPE "public"."VaultProviderType" ADD VALUE 'scaleway';
File diff suppressed because it is too large Load Diff
+7
View File
@@ -1289,6 +1289,13 @@
"when": 1786463845579,
"tag": "0183_tearful_groot",
"breakpoints": true
},
{
"idx": 184,
"version": "7",
"when": 1786500135945,
"tag": "0184_wonderful_absorbing_man",
"breakpoints": true
}
]
}
@@ -10,6 +10,7 @@ export const vaultProviderType = pgEnum("VaultProviderType", [
"aws",
"doppler",
"azure",
"scaleway",
]);
export const hashicorpVaultConfigSchema = z.object({
@@ -53,12 +54,21 @@ export const azureVaultConfigSchema = z.object({
clientSecret: z.string().min(1),
});
export const scalewayVaultConfigSchema = z.object({
providerType: z.literal("scaleway"),
region: z.string().min(1).default("fr-par"),
projectId: z.string().min(1),
secretKey: z.string().min(1),
apiUrl: z.string().url().default("https://api.scaleway.com"),
});
export const vaultProviderConfigSchema = z.discriminatedUnion("providerType", [
hashicorpVaultConfigSchema,
infisicalVaultConfigSchema,
awsVaultConfigSchema,
dopplerVaultConfigSchema,
azureVaultConfigSchema,
scalewayVaultConfigSchema,
]);
export type VaultProviderConfig = z.infer<typeof vaultProviderConfigSchema>;
@@ -22,6 +22,7 @@ const SENSITIVE_FIELDS: Record<VaultProviderConfig["providerType"], string[]> =
aws: ["secretAccessKey"],
doppler: ["serviceToken"],
azure: ["clientSecret"],
scaleway: ["secretKey"],
};
export const maskVaultProviderConfig = (
Binary file not shown.
+173
View File
@@ -0,0 +1,173 @@
import type { scalewayVaultConfigSchema } from "@dokploy/server/db/schema";
import type { z } from "zod";
import { type VaultClient, vaultFetch } from "./types";
type ScalewayConfig = z.infer<typeof scalewayVaultConfigSchema>;
const REVISION = "latest_enabled";
const PAGE_SIZE = 100;
const MAX_LISTED_SECRETS = 500;
const baseUrl = (config: ScalewayConfig) =>
`${config.apiUrl.replace(/\/+$/, "")}/secret-manager/v1beta1/regions/${config.region}`;
/**
* Refs are `[folder/]name[:field]`, mirroring how Scaleway splits a secret into
* a path (folder) and a name. `field` extracts a key from a JSON payload.
*/
export const parseScalewayRef = (ref: string) => {
const separatorIndex = ref.lastIndexOf(":");
const field = separatorIndex === -1 ? null : ref.slice(separatorIndex + 1);
const fullName = separatorIndex === -1 ? ref : ref.slice(0, separatorIndex);
const slashIndex = fullName.lastIndexOf("/");
const name = fullName.slice(slashIndex + 1);
if (!name) {
throw new Error(
`Invalid Scaleway Secret Manager reference "${ref}": expected format <name> or <folder>/<name>[:field]`,
);
}
const path = slashIndex === -1 ? "/" : `/${fullName.slice(0, slashIndex)}`;
return { path: path.replace(/\/+$/, "") || "/", name, field };
};
const request = async (
config: ScalewayConfig,
path: string,
params: Record<string, string>,
notFoundMessage?: string,
) => {
const url = `${baseUrl(config)}${path}?${new URLSearchParams(params).toString()}`;
const response = await vaultFetch(url, {
headers: { "X-Auth-Token": config.secretKey, Accept: "application/json" },
});
if (response.ok) {
return response;
}
if (response.status === 404 && notFoundMessage) {
throw new Error(`Scaleway Secret Manager: ${notFoundMessage}`);
}
let detail = "";
try {
const body = (await response.json()) as {
message?: string;
error?: string;
};
detail = body.message ?? body.error ?? "";
} catch {}
const reason =
response.status === 401 || response.status === 403
? "authentication failed"
: "request failed";
throw new Error(
`Scaleway Secret Manager: ${reason} (status ${response.status}${detail ? `: ${detail}` : ""})`,
);
};
const accessSecret = async (
config: ScalewayConfig,
secretPath: string,
secretName: string,
) => {
const response = await request(
config,
`/secrets-by-path/versions/${REVISION}/access`,
{
project_id: config.projectId,
secret_name: secretName,
secret_path: secretPath,
},
`secret "${secretName}" not found in path "${secretPath}"`,
);
const data = (await response.json()) as { data?: string };
if (data.data === undefined) {
throw new Error(
`Scaleway Secret Manager: secret "${secretName}" has no enabled version`,
);
}
return Buffer.from(data.data, "base64").toString("utf-8");
};
export const scalewayClient: VaultClient<ScalewayConfig> = {
async getSecrets(config, refs) {
const parsed = refs.map((ref) => ({ ref, ...parseScalewayRef(ref) }));
const secretKey = (entry: { path: string; name: string }) =>
`${entry.path}#${entry.name}`;
const payloads = new Map<string, string>();
await Promise.all(
[
...new Map(parsed.map((entry) => [secretKey(entry), entry])).values(),
].map(async (entry) => {
payloads.set(
secretKey(entry),
await accessSecret(config, entry.path, entry.name),
);
}),
);
const result: Record<string, string> = {};
for (const entry of parsed) {
const payload = payloads.get(secretKey(entry)) as string;
const { ref, name, field } = entry;
if (field === null) {
result[ref] = payload;
continue;
}
let json: Record<string, unknown>;
try {
json = JSON.parse(payload);
} catch {
throw new Error(
`Scaleway Secret Manager: secret "${name}" is not JSON, cannot extract field "${field}"`,
);
}
const value = json[field];
if (value === undefined || value === null) {
throw new Error(
`Scaleway Secret Manager: field "${field}" not found in secret "${name}"`,
);
}
result[ref] = typeof value === "string" ? value : JSON.stringify(value);
}
return result;
},
async testConnection(config) {
await request(config, "/secrets", {
project_id: config.projectId,
page_size: "1",
});
},
async listSecretNames(config) {
const names: string[] = [];
let page = 1;
while (names.length < MAX_LISTED_SECRETS) {
const response = await request(config, "/secrets", {
project_id: config.projectId,
page: String(page),
page_size: String(PAGE_SIZE),
});
const data = (await response.json()) as {
secrets?: { name: string; path?: string }[];
};
const secrets = data.secrets ?? [];
for (const secret of secrets) {
const folder = (secret.path ?? "/").replace(/^\/+|\/+$/g, "");
names.push(folder ? `${folder}/${secret.name}` : secret.name);
}
if (secrets.length < PAGE_SIZE) {
break;
}
page += 1;
}
return names.slice(0, MAX_LISTED_SECRETS);
},
};