fix(quota): block qtSd models for keys with no quota-pool allocation (Check 2.9)

E2E testing on the VPS showed a normal key (empty allowedQuotas) could call a
qtSd/<group>/<provider>/<model> virtual model and route through a shared quota
pool — because the quota-exclusive enforcement (Check 3) only ran when
allowedQuotas was non-empty, so an unallocated key fell through to the normal
model checks and qtSd was served. This is the "empty allowedQuotas = all pools"
gap from the redesign.

Add Check 2.9 in enforceApiKeyPolicy: if the requested model is a qtSd model and
the key is NOT allocated to any quota pool (allowedQuotas empty), reject 403
QUOTA_NOT_ALLOCATED. Allocated keys are unchanged (Check 3 still validates scope).
This matches the owner's rule: only a key selected in a pool may use its qtSd
models. Normal (non-qtSd) model access for normal keys is unchanged.

Test: tests/unit/apikeypolicy-quota-only.test.ts — new case asserts a non-quota
key is blocked from qtSd (QUOTA_NOT_ALLOCATED) yet still uses normal models.
This commit is contained in:
diegosouzapw
2026-06-02 07:40:08 -03:00
parent 8277b98003
commit ccaa0b5f79
2 changed files with 47 additions and 0 deletions

View File

@@ -321,6 +321,33 @@ export async function enforceApiKeyPolicy(
}
}
// ── Check 2.9: qtSd models require a quota-pool allocation ──
//
// quotaShared-* (qtSd/<group>/<provider>/<model>) virtual models are pool-gated:
// a key that is NOT allocated to any quota pool (empty allowedQuotas) must not be
// able to call them — otherwise an ordinary key could route through someone
// else's shared quota. Only allocated keys (allowedQuotas non-empty, further
// validated against their pool scope in Check 3 below) may use qtSd models.
if (
modelStr &&
isQuotaModelName(modelStr) &&
!(Array.isArray(apiKeyInfo.allowedQuotas) && apiKeyInfo.allowedQuotas.length > 0)
) {
const notAllocatedBody = buildErrorBody(
HTTP_STATUS.FORBIDDEN,
`Model "${modelStr}" requires a quota-pool allocation; this API key is not allocated to any quota pool`
);
notAllocatedBody.error.code = "QUOTA_NOT_ALLOCATED";
return {
apiKey,
apiKeyInfo,
rejection: new Response(JSON.stringify(notAllocatedBody), {
status: HTTP_STATUS.FORBIDDEN,
headers: { "Content-Type": "application/json" },
}),
};
}
// ── Check 3: Quota-exclusive enforcement (Phase B4) ──
//
// When a key has allowedQuotas its access is governed exclusively by the

View File

@@ -236,6 +236,26 @@ test("key with empty allowedQuotas is subject to normal model restriction checks
assert.notEqual(body.error.code, "QUOTA_ONLY", "normal key rejection must NOT use QUOTA_ONLY code");
});
test("non-quota key (empty allowedQuotas) requesting a qtSd model is rejected 403 QUOTA_NOT_ALLOCATED", async () => {
// A normal key with NO quota allocation must NOT route through a shared quota pool.
const created = await apiKeysDb.createApiKey("Normal Key No Quota", "machine-b4-noalloc");
// (no allowedQuotas set → empty array)
const policy = await loadPolicy("b4-quota-noalloc");
const qtSdModel = quotaModelName("AnyGroup", "codex", "gpt-5.5");
const blocked = await policy.enforceApiKeyPolicy(makeRequest(created.key), qtSdModel);
assert.ok(blocked.rejection, "non-quota key must be blocked from qtSd models");
assert.equal(blocked.rejection.status, 403);
const body = await readBody(blocked.rejection);
assert.equal(body.error.code, "QUOTA_NOT_ALLOCATED", "must use QUOTA_NOT_ALLOCATED code");
assert.match(body.error.message, /quota-pool allocation/);
// Sanity: the same key can still use a normal (non-qtSd) model freely.
const allowed = await policy.enforceApiKeyPolicy(makeRequest(created.key), "openai/gpt-4.1");
assert.equal(allowed.rejection, null, "non-quota key still uses normal models freely");
});
test("quota-only key whose allowedQuotas references a non-existent pool is rejected 403 QUOTA_ONLY (fail-closed)", async () => {
// Create an API key bound to a pool ID that does not exist in the DB (dangling reference)
const created = await apiKeysDb.createApiKey("Dangling Quota Key", "machine-b4-dangling");