diff --git a/.changeset/admin-api-key-pagination.md b/.changeset/admin-api-key-pagination.md new file mode 100644 index 00000000..fff10fa5 --- /dev/null +++ b/.changeset/admin-api-key-pagination.md @@ -0,0 +1,5 @@ +--- +'lens': minor +--- + +Add pagination and a revoked filter to `GET /admin/keys`. `limit` (default 50, max 100) and `offset` page the listing, `revoked=true|false` (default `false`) selects live or revoked keys, and the response adds `pagination` metadata with the total matching count. Invalid values return 400. diff --git a/docs/admin-api-keys.md b/docs/admin-api-keys.md new file mode 100644 index 00000000..a6a3a5bd --- /dev/null +++ b/docs/admin-api-keys.md @@ -0,0 +1,21 @@ +# 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, scopes, rate and quota settings, creation time, expiry, last-used time +(`lastUsedAt`, null if the key has never authenticated) 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 ea3237c4..ef733d72 100644 --- a/src/__tests__/auth.test.ts +++ b/src/__tests__/auth.test.ts @@ -9,6 +9,7 @@ vi.mock('../db', () => ({ apiKey: { findUnique: vi.fn(), findMany: vi.fn(), + count: vi.fn(), create: vi.fn(), update: vi.fn(), }, @@ -44,6 +45,7 @@ import { 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 @@ -80,6 +82,7 @@ beforeEach(() => { resetApiKeyLastUsedThrottle() mockFindUnique.mockReset() mockFindMany.mockReset() + mockCount.mockReset() mockCreate.mockReset() mockUpdate.mockReset() // The lookup cache is module-global; a stale entry from a previous test @@ -527,6 +530,69 @@ 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, scopes: ['read:prices'], lastUsedAt: 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', + scopes: ['read:prices'], createdAt: createdAt.toISOString(), revokedAt: null, expiresAt: null, lastUsedAt: 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 295636a1..eb034034 100644 --- a/src/api/admin.ts +++ b/src/api/admin.ts @@ -25,6 +25,21 @@ interface RotateKeyBody { overlapHours?: number } +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, listing, and revoking API keys. * @@ -128,10 +143,10 @@ export async function registerAdminRoutes(app: FastifyInstance) { }, ) - app.get( + app.get<{ Querystring: ListKeysQuery }>( '/admin/keys', { config: { admin: true, auth: 'admin' } }, - async (req: FastifyRequest, reply: FastifyReply) => { + async (req: FastifyRequest<{ Querystring: ListKeysQuery }>, reply: FastifyReply) => { if (!isAdminAuthorized(req)) { admin_auth_failures_total.inc() return reply.status(401).send({ @@ -141,27 +156,59 @@ export async function registerAdminRoutes(app: FastifyInstance) { }) } - const keys = await prisma.apiKey.findMany({ - orderBy: { createdAt: 'desc' }, - select: { - id: true, - label: true, - scopes: true, - ratePerMin: true, - ratePerDay: true, - revokedAt: true, - expiresAt: true, - createdAt: true, - // #500: null means "never authenticated since this column existed", - // which is what makes an abandoned key visible to an operator. - lastUsedAt: true, - monthlyQuotaCents: true, - dailyQuotaCents: true, - overagePolicy: true, - }, - }) + 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 [keys, total] = await Promise.all([ + prisma.apiKey.findMany({ + where, + skip: offset, + take: limit, + orderBy: { createdAt: 'desc' }, + select: { + id: true, + label: true, + scopes: true, + ratePerMin: true, + ratePerDay: true, + revokedAt: true, + expiresAt: true, + createdAt: true, + // #500: null means "never authenticated since this column existed", + // which is what makes an abandoned key visible to an operator. + lastUsedAt: true, + monthlyQuotaCents: true, + dailyQuotaCents: true, + overagePolicy: true, + }, + }), + prisma.apiKey.count({ where }), + ]) - return reply.status(200).send({ keys }) + return reply.status(200).send({ + keys: keys.map(record => ({ + id: record.id, + label: record.label, + scopes: record.scopes, + ratePerMin: record.ratePerMin, + ratePerDay: record.ratePerDay, + revokedAt: record.revokedAt, + expiresAt: record.expiresAt, + createdAt: record.createdAt, + lastUsedAt: record.lastUsedAt, + monthlyQuotaCents: record.monthlyQuotaCents, + dailyQuotaCents: record.dailyQuotaCents, + overagePolicy: record.overagePolicy, + })), + pagination: { limit, offset, total }, + }) }, )