Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
127 changes: 68 additions & 59 deletions PR_DESCRIPTION.md
Original file line number Diff line number Diff line change
@@ -1,72 +1,81 @@
# PR Description for Issue #236
# task: enforce unique active api_key prefixes

## chore(backend): add pagination defaults and max limits enforcement across list endpoints
## Summary

### Summary
The gateway auth flow in `src/middleware/gatewayApiKeyAuth.ts` performs a
prefix-based lookup before a timing-safe full-key hash comparison. Without a
database-level guarantee, two active keys could share the same prefix, making
the lookup ambiguous and potentially allowing one key to shadow another.

This PR addresses issue #236 by implementing consistent pagination defaults and maximum limit enforcement across all list endpoints in the `callora-backend` repository. This improves API consistency, performance, and protects against unbounded queries that could be used for DoS attacks.
This PR adds the missing constraint and its regression tests.

### 🧪 Implementation Details
---

- **Core Pagination Helper**: Updated `src/lib/pagination.ts` to support both `offset/limit` and `page/limit` pagination.
- **Default Limits**: Enforced a `DEFAULT_LIMIT` of 20 and a `MAX_LIMIT` of 100.
- **Input Normalization**: Parsed and clamped invalid inputs (NaN, negative, zero) to safe defaults.
- **Refactoring**: Updated `src/app.ts`, `src/routes/admin.ts`, and `src/routes/developerRoutes.ts` to use the shared `parsePagination` and `paginatedResponse` helpers consistently.
- **Repository Updates**: Extended `UsageEventsRepository` (both In-Memory and PG implementations) to support pagination (`limit` and `offset`).
- **Endpoint Improvements**: Implemented full public API listing in `GET /api/apis` and standardized response formats.
## Changes

### 📋 Key Findings & Security Notes
### `migrations/0006_api_key_prefix_unique.sql` (new)
Adds a **partial unique index** on `api_keys (prefix) WHERE revoked = FALSE`.

#### Security and Data Integrity Assumptions
⚠️ **Security Note**: By enforcing a `MAX_LIMIT` of 100, we prevent potentially expensive database queries that could return thousands of rows, which could be used as an application-layer DoS vector.
- **Input Sanitization**: All pagination parameters are parsed as integers and clamped to safe ranges (limit 1-100, offset >= 0). This prevents unexpected behavior or SQL injection through pagination parameters.
- **Consistency**: Using a single source of truth (`parsePagination`) ensures that all list endpoints behave identically, reducing developer error when adding new endpoints.
- **Default Behavior**: If no pagination parameters are provided, the system defaults to the first page (offset 0) with a limit of 20, ensuring stable and predictable API responses without overwhelming the backend or client.
```sql
CREATE UNIQUE INDEX IF NOT EXISTS uq_api_keys_prefix_active
ON api_keys (prefix)
WHERE revoked = FALSE;
```

### 📁 Files Changed
- Active keys are guaranteed to have unique prefixes at the database level.
- Revoked keys are excluded so a prefix can be reused after revocation.

- `src/lib/pagination.ts` - Core pagination logic
- `src/lib/__tests__/pagination.test.ts` - Unit tests
- `src/app.ts` - Refactored endpoints
- `src/repositories/usageEventsRepository.ts` - Interface updates
- `src/repositories/usageEventsRepository.pg.ts` - PostgreSQL implementation updates
- `src/routes/admin.ts` - Admin routes
- `src/routes/developerRoutes.ts` - Developer analytics routes
### `migrations/0006_api_key_prefix_unique.down.sql` (new)
Rollback: `DROP INDEX IF EXISTS uq_api_keys_prefix_active;`

### 🚀 Test Results
### `src/repositories/apiKeyRepository.prefix.test.ts` (new)
Constraint regression tests using **pg-mem** (no external DB required):

| Test | Covers |
|------|--------|
| Allows unique active prefix | Happy path |
| Allows two active keys with different prefixes | Happy path |
| Rejects duplicate active prefix | Collision — acceptance criterion 1 |
| Rejects duplicate prefix for different `api_id` | Collision variant |
| Allows prefix reuse after revocation | Revocation — acceptance criterion 2 |
| Allows multiple revoked keys with same prefix | Revocation variant |
| Does not block new active key when revoked key exists | Revocation — acceptance criterion 3 |
| Allows multiple NULL-prefix rows | Documents NULL semantics |
| Prefix lookup returns exactly one row | Mirrors gateway middleware query |
| Prefix lookup returns zero rows after revocation | Mirrors gateway middleware query |

### `tests/helpers/db.ts` (updated)
Added `prefix VARCHAR(16)` column and the partial unique index to the shared
pg-mem schema used by integration tests, keeping it in sync with the real
PostgreSQL schema.

### `docs/gateway-api-key-auth.md` (updated)
Added a **Prefix uniqueness guarantee** section documenting the new index and
pointing to the regression tests.

### `migrations/README.md` (updated)
Added migration 0006 to the table.

---

## Acceptance criteria

- [x] Inserting a duplicate active prefix fails at the database level
- [x] Revoked keys do not block reuse of a prefix
- [x] Tests cover collision and revocation cases

---

## Testing

Tests run with Jest + pg-mem (no external PostgreSQL required):

```bash
npm test -- --testPathPattern="apiKeyRepository.prefix.test"
```
▶ parsePagination
✔ returns defaults when no query params given
✔ parses valid limit and offset
✔ clamps limit to max 100
✔ clamps limit to min 1
✔ clamps offset to min 0
✔ handles non-numeric strings gracefully
✔ truncates floating-point limit via parseInt
✔ clamps a huge limit (Number.MAX_SAFE_INTEGER) to 100
✔ calculates offset based on page and limit
✔ uses default limit when only page is provided
✔ prefers page over offset when both are provided
✔ handles invalid page values gracefully
...
✔ parsePagination (2.98ms)
▶ paginatedResponse
✔ wraps data and meta into the envelope
✔ works without total in meta
✔ returns exactly "data" and "meta" top-level keys
...
✔ paginatedResponse (1.05ms)

ℹ tests 32
ℹ suites 2
ℹ pass 32
ℹ fail 0
```
- **Total Test Cases**: 32 unit tests
- **Result**: All passing

### 🔧 Commands Run
- `npm run lint` - Success (0 errors)
- `npx tsx --test src/lib/__tests__/pagination.test.ts` - Success (32/32 tests passed)
- `git checkout -b feature/pagination-defaults-max` - Success
All 10 constraint regression tests pass.

---

closes #309
13 changes: 13 additions & 0 deletions docs/gateway-api-key-auth.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,3 +71,16 @@ The database-backed middleware supports:
To support revocation in environments that do not yet have the column, apply:

- `migrations/0005_add_api_key_revocation.sql`

### Prefix uniqueness guarantee

Migration `0006_api_key_prefix_unique.sql` adds a **partial unique index** on
`api_keys (prefix) WHERE revoked = FALSE`. This guarantees that the
prefix-based lookup in step 3 of the validation flow always returns at most one
active candidate, eliminating any ambiguity before the full hash comparison.

Revoked keys are excluded from the index so a prefix can be legitimately reused
after a key is revoked (e.g. after rotation).

Constraint regression tests live in:
`src/repositories/apiKeyRepository.prefix.test.ts`
4 changes: 4 additions & 0 deletions migrations/0006_api_key_prefix_unique.down.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
-- Rollback: 0006_api_key_prefix_unique
-- Drops the partial unique index that enforces prefix uniqueness among active keys.

DROP INDEX IF EXISTS uq_api_keys_prefix_active;
15 changes: 15 additions & 0 deletions migrations/0006_api_key_prefix_unique.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
-- Migration: 0006_api_key_prefix_unique
-- Purpose: Enforce uniqueness of api_keys.prefix among active (non-revoked) keys.
--
-- The gateway auth flow in src/middleware/gatewayApiKeyAuth.ts performs a
-- prefix-based lookup before a timing-safe full-key hash comparison. Without
-- a database-level guarantee, two active keys could share the same prefix,
-- making the lookup ambiguous and potentially allowing one key to shadow another.
--
-- A partial unique index (WHERE revoked = FALSE) is used instead of a plain
-- UNIQUE constraint so that revoked keys do not block prefix reuse — a new
-- active key may legitimately reuse a prefix that was previously revoked.

CREATE UNIQUE INDEX IF NOT EXISTS uq_api_keys_prefix_active
ON api_keys (prefix)
WHERE revoked = FALSE;
1 change: 1 addition & 0 deletions migrations/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ Examples:
| 005 | `005_add_persistent_store_columns.sql` | Adds `external_id`, `api_key`, `status_code` columns |
| 0004 | `0004_create_developers.sql` | `developers` profile table |
| 0005 | `0005_add_api_key_revocation.sql` | Adds `revoked` column to `api_keys` |
| 0006 | `0006_api_key_prefix_unique.sql` | Partial unique index on `api_keys.prefix` for active keys |

> **Note:** `add_refresh_tokens.sql` lacks a numeric prefix and will be rejected by the runner.
> It must be renamed to `0006_add_refresh_tokens.sql` (or the next available number) before use.
Expand Down
Loading