mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-10-15 13:21:11 +00:00
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:
+176
@@ -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
@@ -1282,6 +1282,13 @@
|
||||
"when": 1786435879438,
|
||||
"tag": "0182_skinny_wild_pack",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 183,
|
||||
"version": "7",
|
||||
"when": 1786461265052,
|
||||
"tag": "0183_legal_hydra",
|
||||
"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.
@@ -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);
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user