diff --git a/apps/dokploy/__test__/dns/ovh.test.ts b/apps/dokploy/__test__/dns/ovh.test.ts index 5fe829d27..4f70d4c6c 100644 --- a/apps/dokploy/__test__/dns/ovh.test.ts +++ b/apps/dokploy/__test__/dns/ovh.test.ts @@ -149,14 +149,23 @@ describe("ovhClient.listZones", () => { ]); }); - it("propagates the API error message", async () => { + it("names the missing root right when OVH refuses the zone listing", async () => { const cfg = freshConfig(); mockApi(ovhError("This call has not been granted", 403)); + // A `GET /domain/zone/*` rule does not cover the bare `GET /domain/zone`, + // so the raw OVH message would send users looking in the wrong place. await expect(ovhClient.listZones(cfg)).rejects.toThrow( - "This call has not been granted", + /missing the `GET \/domain\/zone` right/, ); }); + + it("propagates the API error message", async () => { + const cfg = freshConfig(); + mockApi(ovhError("Invalid signature", 403)); + + await expect(ovhClient.listZones(cfg)).rejects.toThrow("Invalid signature"); + }); }); describe("ovhClient.listRecords", () => { diff --git a/apps/dokploy/components/dashboard/settings/dns/handle-dns-provider.tsx b/apps/dokploy/components/dashboard/settings/dns/handle-dns-provider.tsx index f5d475c29..7d21f42bd 100644 --- a/apps/dokploy/components/dashboard/settings/dns/handle-dns-provider.tsx +++ b/apps/dokploy/components/dashboard/settings/dns/handle-dns-provider.tsx @@ -488,10 +488,22 @@ export const HandleDnsProvider = ({ dnsProviderId }: Props) => { - Create the three keys at once on api.ovh.com/createToken - with the GET, POST,{" "} - PUT and DELETE rights on{" "} - /domain/zone/*. + Create the three keys at once on + api.ovh.com/createToken, with exactly these five rights: +
+ GET /domain/zone +
+ GET /domain/zone/* +
+ POST /domain/zone/* +
+ PUT /domain/zone/* +
+ DELETE /domain/zone/* +
+ The first one lists your zones and has to be granted on + its own: OVH matches rights per exact path, so{" "} + /domain/zone/* does not cover it.
diff --git a/packages/server/src/utils/dns/ovh.ts b/packages/server/src/utils/dns/ovh.ts index 58c372356..b24666193 100644 --- a/packages/server/src/utils/dns/ovh.ts +++ b/packages/server/src/utils/dns/ovh.ts @@ -209,9 +209,29 @@ const recordBody = ( ...(record.ttl === undefined ? {} : { ttl: record.ttl }), }); +// OVH grants access per exact path: a `/domain/zone/*` rule covers the subtree +// but not the bare `/domain/zone` listing, which needs its own rule. That is an +// easy one to leave out of a token, so say so plainly rather than surfacing a +// bare "This call has not been granted". +const listZoneNames = async (config: OvhConfig) => { + try { + return await ovhFetch(config, "/domain/zone"); + } catch (error) { + if ( + error instanceof Error && + error.message.includes("has not been granted") + ) { + throw new Error( + "OVH: the credentials are missing the `GET /domain/zone` right, which lists your zones. A `GET /domain/zone/*` rule does not cover it — add the rule without the wildcard as well.", + ); + } + throw error; + } +}; + export const ovhClient: DnsClient = { async listZones(config) { - const zones = await ovhFetch(config, "/domain/zone"); + const zones = await listZoneNames(config); return zones.map((zone) => ({ id: zone, name: zone })); }, @@ -318,6 +338,6 @@ export const ovhClient: DnsClient = { }, async testConnection(config) { - await ovhFetch(config, "/domain/zone"); + await listZoneNames(config); }, };