Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/admin-api-key-pagination.md
Original file line number Diff line number Diff line change
@@ -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.
21 changes: 21 additions & 0 deletions docs/admin-api-keys.md
Original file line number Diff line number Diff line change
@@ -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: <admin-token>
```

The response contains a `keys` array and `pagination` metadata including the
total number of records matching the revoked filter.
66 changes: 66 additions & 0 deletions src/__tests__/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ vi.mock('../db', () => ({
apiKey: {
findUnique: vi.fn(),
findMany: vi.fn(),
count: vi.fn(),
create: vi.fn(),
update: vi.fn(),
},
Expand Down Expand Up @@ -44,6 +45,7 @@ import {

const mockFindUnique = prisma.apiKey.findUnique as unknown as ReturnType<typeof vi.fn>
const mockFindMany = prisma.apiKey.findMany as unknown as ReturnType<typeof vi.fn>
const mockCount = prisma.apiKey.count as unknown as ReturnType<typeof vi.fn>
const mockCreate = prisma.apiKey.create as unknown as ReturnType<typeof vi.fn>
const mockUpdate = prisma.apiKey.update as unknown as ReturnType<typeof vi.fn>

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down
91 changes: 69 additions & 22 deletions src/api/admin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -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({
Expand All @@ -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 },
})
},
)

Expand Down