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
@@ -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);
},
};