feat(api-keys): restrict keys to source IP addresses

A key can be limited to IP addresses or CIDR networks (e.g. the Wabenhain
server); a stolen key is useless from anywhere else. Requests from other
addresses get 403 "not allowed for this IP" and a log line. The admin UI
asks for the addresses when creating a key, warns when a key works from
anywhere, shows the last source address and can take it over into the
allowlist - changing the list needs no new key (PATCH /api-keys/:id).

Also: a key can only carry scopes its creator holds.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-01 02:41:46 +02:00
1 parent 9ae1d96a32
commit f1dc0de3e2
16 files changed
+463 -10

No files matched your search

+38
View File
@@ -190,4 +190,42 @@ describe('API keys (e2e)', () => {
.send({ name: 'x', scopes: ['shop-sync:read'] })
.expect(403);
});
it('works only from allowed addresses and records where it was used', async () => {
const elsewhere = (
await createKey({
name: `Elsewhere ${suffix}`,
scopes: ['shop-sync:read'],
allowedIps: ['203.0.113.7'],
}).expect(201)
).body as CreatedKeyBody;
await request(server())
.get('/api/v1/shop-sync')
.set('Authorization', `Bearer ${elsewhere.token}`)
.expect(403);
// Allow loopback (where this test runs) afterwards, without a new key.
await request(server())
.patch(`/api/v1/api-keys/${elsewhere.apiKey.id}`)
.set('Authorization', `Bearer ${adminToken}`)
.send({ allowedIps: ['127.0.0.0/8', '::1'] })
.expect(200);
await request(server())
.get('/api/v1/shop-sync')
.set('Authorization', `Bearer ${elsewhere.token}`)
.expect(200);
const stored = await prisma.apiKey.findUniqueOrThrow({
where: { id: elsewhere.apiKey.id },
});
expect(stored.lastUsedIp).toMatch(/127\.0\.0\.1|::1/);
});
it('refuses an allowlist entry that is not an address or network', async () => {
await createKey({
name: `Bad allowlist ${suffix}`,
scopes: ['shop-sync:read'],
allowedIps: ['example.com'],
}).expect(400);
});
});