From e42eee47ff998a88dedbd98156cada47b239660a Mon Sep 17 00:00:00 2001 From: Zuliyat <173554666+Zuliyat@users.noreply.github.com> Date: Wed, 21 Oct 2026 03:12:26 +0100 Subject: [PATCH] feat(admin): add paginated API key listing --- docs/admin-api-keys.md | 20 ++++++++++ src/__tests__/auth.test.ts | 68 ++++++++++++++++++++++++++++++++ src/api/admin.ts | 79 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 167 insertions(+) create mode 100644 docs/admin-api-keys.md diff --git a/docs/admin-api-keys.md b/docs/admin-api-keys.md new file mode 100644 index 00000000..ba13ab30 --- /dev/null +++ b/docs/admin-api-keys.md @@ -0,0 +1,20 @@ +# Managing API keys + +All `/admin/keys` routes require the `ADMIN_TOKEN` configured by the operator. +The token can be sent in `X-Admin-Token` or as a bearer token. + +`GET /admin/keys` returns the live keys by default. It returns key identifiers, +labels, rate and quota settings, creation time, expiry and revocation state; it +never returns the stored hash or plaintext key. + +Pagination uses `limit` and `offset`. The default page size is 50 and the +maximum is 100. Set `revoked=true` to list revoked keys instead; the default +`revoked=false` view hides them. + +```http +GET /admin/keys?limit=50&offset=0&revoked=false +X-Admin-Token: +``` + +The response contains a `keys` array and `pagination` metadata including the +total number of records matching the revoked filter. diff --git a/src/__tests__/auth.test.ts b/src/__tests__/auth.test.ts index 77915164..3aa541de 100644 --- a/src/__tests__/auth.test.ts +++ b/src/__tests__/auth.test.ts @@ -8,6 +8,8 @@ vi.mock('../db', () => ({ prisma: { apiKey: { findUnique: vi.fn(), + findMany: vi.fn(), + count: vi.fn(), create: vi.fn(), update: vi.fn(), }, @@ -25,6 +27,8 @@ import { registerAdminRoutes, generateApiKey } from '../api/admin' import { register, api_key_auth_failures_total, admin_auth_failures_total } from '../metrics' const mockFindUnique = prisma.apiKey.findUnique as unknown as ReturnType +const mockFindMany = prisma.apiKey.findMany as unknown as ReturnType +const mockCount = prisma.apiKey.count as unknown as ReturnType const mockCreate = prisma.apiKey.create as unknown as ReturnType const mockUpdate = prisma.apiKey.update as unknown as ReturnType @@ -56,6 +60,8 @@ async function buildAuthedApp() { beforeEach(() => { mockFindUnique.mockReset() + mockFindMany.mockReset() + mockCount.mockReset() mockCreate.mockReset() mockUpdate.mockReset() }) @@ -270,6 +276,68 @@ describe('admin endpoints', () => { expect(k).toMatch(/^lens_[a-f0-9]{48}$/) }) + it('lists only safe key metadata, paginates, and hides revoked keys by default', async () => { + const createdAt = new Date('2026-10-01T12:00:00.000Z') + mockFindMany.mockResolvedValue([{ + id: 'key-1', label: 'acme', ratePerMin: 120, ratePerDay: 5000, + monthlyQuotaCents: 20000, dailyQuotaCents: 1000, overagePolicy: 'block', + createdAt, revokedAt: null, expiresAt: null, hash: 'do-not-return-this-hash', + }]) + mockCount.mockResolvedValue(3) + const app = await buildAdminApp() + const res = await app.inject({ + method: 'GET', url: '/admin/keys?limit=1&offset=2', + headers: { 'x-admin-token': 'admin-secret' }, + }) + + expect(res.statusCode).toBe(200) + expect(mockFindMany).toHaveBeenCalledWith(expect.objectContaining({ + where: { revokedAt: null }, skip: 2, take: 1, + select: expect.not.objectContaining({ hash: true }), + })) + expect(mockCount).toHaveBeenCalledWith({ where: { revokedAt: null } }) + expect(res.json()).toEqual({ + keys: [{ + id: 'key-1', label: 'acme', ratePerMin: 120, ratePerDay: 5000, + monthlyQuotaCents: 20000, dailyQuotaCents: 1000, overagePolicy: 'block', + createdAt: createdAt.toISOString(), revokedAt: null, expiresAt: null, + }], + pagination: { limit: 1, offset: 2, total: 3 }, + }) + expect(JSON.stringify(res.json())).not.toContain('do-not-return-this-hash') + }) + + it('requires the admin token to list API keys', async () => { + const before = (await admin_auth_failures_total.get()).values[0]?.value ?? 0 + const app = await buildAdminApp() + const res = await app.inject({ method: 'GET', url: '/admin/keys' }) + expect(res.statusCode).toBe(401) + expect(mockFindMany).not.toHaveBeenCalled() + expect((await admin_auth_failures_total.get()).values[0]?.value).toBe(before + 1) + }) + + it('can include revoked keys when explicitly requested', async () => { + mockFindMany.mockResolvedValue([]) + mockCount.mockResolvedValue(0) + const app = await buildAdminApp() + const res = await app.inject({ + method: 'GET', url: '/admin/keys?revoked=true', + headers: { 'x-admin-token': 'admin-secret' }, + }) + expect(res.statusCode).toBe(200) + expect(mockFindMany).toHaveBeenCalledWith(expect.objectContaining({ where: { revokedAt: { not: null } } })) + }) + + it('rejects invalid key-list pagination and filters', async () => { + const app = await buildAdminApp() + const res = await app.inject({ + method: 'GET', url: '/admin/keys?limit=1000&revoked=maybe', + headers: { 'x-admin-token': 'admin-secret' }, + }) + expect(res.statusCode).toBe(400) + expect(mockFindMany).not.toHaveBeenCalled() + }) + it('rejects minting without the admin token', async () => { const before = (await admin_auth_failures_total.get()).values[0]?.value ?? 0 const app = await buildAdminApp() diff --git a/src/api/admin.ts b/src/api/admin.ts index 6cd01f68..7f525499 100644 --- a/src/api/admin.ts +++ b/src/api/admin.ts @@ -19,6 +19,21 @@ interface CreateKeyBody { overagePolicy?: 'block' | 'charge_402' | 'allow_overage' } +interface ListKeysQuery { + limit?: string + offset?: string + revoked?: string +} + +const MAX_KEY_PAGE_SIZE = 100 + +function parsePageValue(value: string | undefined, fallback: number, maximum: number): number | null { + if (value === undefined) return fallback + if (!/^(0|[1-9]\d*)$/.test(value)) return null + const parsed = Number(value) + return Number.isSafeInteger(parsed) && parsed <= maximum ? parsed : null +} + /** * Registers admin-only endpoints for minting and revoking API keys. * @@ -29,6 +44,70 @@ interface CreateKeyBody { * them; they enforce their own `ADMIN_TOKEN` check instead. */ export async function registerAdminRoutes(app: FastifyInstance) { + app.get<{ Querystring: ListKeysQuery }>( + '/admin/keys', + { config: { public: true } }, + async (req: FastifyRequest<{ Querystring: ListKeysQuery }>, reply: FastifyReply) => { + if (!isAdminAuthorized(req)) { + admin_auth_failures_total.inc() + return reply.status(401).send({ + error: 'Unauthorized', + code: 'admin_unauthorized', + message: 'Provide a valid X-Admin-Token header.', + }) + } + + const limit = parsePageValue(req.query.limit, 50, MAX_KEY_PAGE_SIZE) + const offset = parsePageValue(req.query.offset, 0, Number.MAX_SAFE_INTEGER) + const revoked = req.query.revoked ?? 'false' + if (limit === null || limit < 1 || offset === null || !['true', 'false'].includes(revoked)) { + return reply.status(400).send({ + error: 'Invalid pagination or revoked filter. Use limit (1-100), offset (0 or greater), and revoked=true|false.', + }) + } + + const where = revoked === 'true' ? { revokedAt: { not: null } } : { revokedAt: null } + const [records, total] = await Promise.all([ + prisma.apiKey.findMany({ + where, + orderBy: { createdAt: 'desc' }, + skip: offset, + take: limit, + // Never select the stored hash; it is not needed to manage a key. + select: { + id: true, + label: true, + ratePerMin: true, + ratePerDay: true, + monthlyQuotaCents: true, + dailyQuotaCents: true, + overagePolicy: true, + createdAt: true, + revokedAt: true, + expiresAt: true, + }, + }), + prisma.apiKey.count({ where }), + ]) + + return reply.send({ + keys: records.map(record => ({ + id: record.id, + label: record.label, + ratePerMin: record.ratePerMin, + ratePerDay: record.ratePerDay, + monthlyQuotaCents: record.monthlyQuotaCents, + dailyQuotaCents: record.dailyQuotaCents, + overagePolicy: record.overagePolicy, + createdAt: record.createdAt, + revokedAt: record.revokedAt, + expiresAt: record.expiresAt, + })), + pagination: { limit, offset, total }, + }) + }, + ) + app.post<{ Body: CreateKeyBody }>( '/admin/keys', { config: { public: true } },