From 64aaa66fa394ba6b45db26f87f33b3bdf9f6008b Mon Sep 17 00:00:00 2001 From: Gabriel Machado Date: Thu, 8 Oct 2026 15:55:04 -0300 Subject: [PATCH] :bug: fix: deny instead of reporting an outage when wave-auth-api answers 403 to validate Co-Authored-By: Claude Opus 5.5 --- package.json | 2 +- .../api-key/permission-validator.ts | 3 +- .../wave-auth-permission-validator.spec.ts | 28 ++++++++++++++++--- .../api-key/wave-auth-permission-validator.ts | 16 +++++++++-- 4 files changed, 40 insertions(+), 9 deletions(-) diff --git a/package.json b/package.json index 9ce9c40..b359f93 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@wave-tech/framework", - "version": "0.17.0", + "version": "0.17.1", "description": "wave tech team shared utils framework", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/src/auth-permission/api-key/permission-validator.ts b/src/auth-permission/api-key/permission-validator.ts index 341dbc0..dbbecaf 100644 --- a/src/auth-permission/api-key/permission-validator.ts +++ b/src/auth-permission/api-key/permission-validator.ts @@ -21,7 +21,8 @@ export interface PermissionValidator { /** * Resolves with the verdict on success. Throws * `PermissionValidatorUnauthorizedError` when the credential itself is - * invalid, revoked or expired, and `PermissionValidatorUnavailableError` + * invalid, revoked or expired, `PermissionDeniedError` when the credential + * is genuine but may not be validated, and `PermissionValidatorUnavailableError` * when no verdict could be obtained at all (timeout, 5xx, malformed * response) — callers must fail closed on the latter. */ diff --git a/src/auth-permission/api-key/wave-auth-permission-validator.spec.ts b/src/auth-permission/api-key/wave-auth-permission-validator.spec.ts index 2179ce2..9932b1e 100644 --- a/src/auth-permission/api-key/wave-auth-permission-validator.spec.ts +++ b/src/auth-permission/api-key/wave-auth-permission-validator.spec.ts @@ -1,6 +1,7 @@ import { describe, it, expect, afterEach, vi } from 'vitest'; import { setHookContext, setHookCorrelationId } from '../../core'; import { WaveAuthPermissionValidator } from './wave-auth-permission-validator'; +import { PermissionDeniedError } from '../errors/permission-denied-error'; import { PermissionValidatorUnauthorizedError } from '../errors/permission-validator-unauthorized-error'; import { PermissionValidatorUnavailableError } from '../errors/permission-validator-unavailable-error'; import type { PermissionValidationRequest } from './permission-validator'; @@ -109,8 +110,27 @@ describe('WaveAuthPermissionValidator — downstream failures', () => { }, ); - it.each([400, 403, 404, 429])( - 'status %i — any 4xx other than 401 is an absent verdict, not a denial', + it('403 from wave-auth-api (a key that may not validate) throws PermissionDeniedError', async () => { + stubFetch(() => + jsonResponse({ title: 'Forbidden', status: 403, detail: 'Insufficient permissions.' }, 403), + ); + + const rejection = buildValidator().validate(REQUEST); + + await expect(rejection).rejects.toBeInstanceOf(PermissionDeniedError); + await expect(rejection).rejects.toThrow( + 'The API key is not allowed to validate the tickets.ticket.create permission', + ); + }); + + it('a 403 denies even when its body claims authorized', async () => { + stubFetch(() => jsonResponse({ authorized: true, brokers: ['tim'] }, 403)); + + await expect(buildValidator().validate(REQUEST)).rejects.toBeInstanceOf(PermissionDeniedError); + }); + + it.each([400, 404, 429])( + 'status %i — any other 4xx is an absent verdict, not a denial', async (status) => { stubFetch(() => textResponse('refused', status)); @@ -120,8 +140,8 @@ describe('WaveAuthPermissionValidator — downstream failures', () => { }, ); - it('a 4xx never produces a verdict, authorized or not', async () => { - stubFetch(() => jsonResponse({ authorized: true, brokers: ['tim'] }, 403)); + it('a 4xx other than 401 and 403 never produces a verdict, authorized or not', async () => { + stubFetch(() => jsonResponse({ authorized: true, brokers: ['tim'] }, 400)); await expect(buildValidator().validate(REQUEST)).rejects.toBeInstanceOf( PermissionValidatorUnavailableError, diff --git a/src/auth-permission/api-key/wave-auth-permission-validator.ts b/src/auth-permission/api-key/wave-auth-permission-validator.ts index c4374be..93c60c6 100644 --- a/src/auth-permission/api-key/wave-auth-permission-validator.ts +++ b/src/auth-permission/api-key/wave-auth-permission-validator.ts @@ -1,4 +1,5 @@ import { z } from 'zod'; +import { PermissionDeniedError } from '../errors/permission-denied-error'; import { PermissionValidatorUnauthorizedError } from '../errors/permission-validator-unauthorized-error'; import { PermissionValidatorUnavailableError } from '../errors/permission-validator-unavailable-error'; import type { @@ -38,9 +39,18 @@ export class WaveAuthPermissionValidator implements PermissionValidator { throw new PermissionValidatorUnauthorizedError(); } - // Only `401` is a verdict about the credential itself. Any other non-2xx - // — `5xx`, a contract `400`, `429` — is the absence of a verdict, and the - // caller must fail closed instead of treating it as a denial. + // `403` is a verdict about the credential too: the key is genuine but may + // not be validated (it lacks `auth.api_key.validate`), so the request is + // denied whatever the body says, never reported as an outage. + if (response.status === 403) { + throw new PermissionDeniedError( + `The API key is not allowed to validate the ${request.permission} permission`, + ); + } + + // Any other non-2xx — `5xx`, a contract `400`, `429` — is the absence of + // a verdict, and the caller must fail closed instead of treating it as a + // denial. if (!response.ok) { throw new PermissionValidatorUnavailableError( `${this.validateUrl} responded ${response.status}`,