fix(dns): spell out the OVH right that lists zones

OVH matches access rules per exact path: a `GET /domain/zone/*` rule grants the
subtree but not the bare `GET /domain/zone` that listZones and testConnection
call. Verified against a live account with a consumer key carrying that single
wildcard rule:

    GET /domain/zone                     -> 403 This call has not been granted
    GET /domain/zone/                    -> 200
    GET /domain/zone/{zone}/record       -> 200

The form only asked for rights on `/domain/zone/*`, so a token created by
following it could not list zones at all, and the failure surfaced as a bare
"This call has not been granted" that points nowhere.

The hint now lists the five rights verbatim, and a token missing the root one
gets an error that names it instead of echoing OVH's message.

Reported by @narcisonunez on #5258.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Guillaume Juge
2026-09-01 21:30:07 +02:00
parent 80a6baf58e
commit f83097b80f
3 changed files with 49 additions and 8 deletions
+11 -2
View File
@@ -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", () => {
@@ -488,10 +488,22 @@ export const HandleDnsProvider = ({ dnsProviderId }: Props) => {
<Input type="password" {...field} />
</FormControl>
<FormDescription>
Create the three keys at once on api.ovh.com/createToken
with the <code>GET</code>, <code>POST</code>,{" "}
<code>PUT</code> and <code>DELETE</code> rights on{" "}
<code>/domain/zone/*</code>.
Create the three keys at once on
api.ovh.com/createToken, with exactly these five rights:
<br />
<code>GET /domain/zone</code>
<br />
<code>GET /domain/zone/*</code>
<br />
<code>POST /domain/zone/*</code>
<br />
<code>PUT /domain/zone/*</code>
<br />
<code>DELETE /domain/zone/*</code>
<br />
The first one lists your zones and has to be granted on
its own: OVH matches rights per exact path, so{" "}
<code>/domain/zone/*</code> does not cover it.
</FormDescription>
<FormMessage />
</FormItem>
+22 -2
View File
@@ -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<string[]>(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<OvhConfig> = {
async listZones(config) {
const zones = await ovhFetch<string[]>(config, "/domain/zone");
const zones = await listZoneNames(config);
return zones.map((zone) => ({ id: zone, name: zone }));
},
@@ -318,6 +338,6 @@ export const ovhClient: DnsClient<OvhConfig> = {
},
async testConnection(config) {
await ovhFetch(config, "/domain/zone");
await listZoneNames(config);
},
};